mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[ADD] runbot_merge: PR dashboard V2
Displays the entire batch set as a table, along both repository (linked PRs) and branch (forward ports). Should provide a much more complete overview. Adds a copy of the dashboard as a raster render, to link from the PR: as usual SVG is shit, content-based viewboxes are hell and having to duplicate the entire CSS because `<img/>`-linked CSS can't run is gross. And there's no payoff since the image is not interactible anyway. Performing manual ad-hoc table rendering via pillow is not significantly worse, it works fine and it's possible to do *really* good conditional request handling (hopefully) because I've basically got all the information I need right here. In fact it might make sense to upgrade the regular HTML page with similar conditional request handling, at least for the last-update bit if not the etag. Fixes #771,fixes #770
This commit is contained in:
parent
3191c44459
commit
232aa271b0
@ -136,31 +136,6 @@
|
|||||||
t-attf-title="@{{pr.reviewed_by.github_login}}"/>
|
t-attf-title="@{{pr.reviewed_by.github_login}}"/>
|
||||||
</dd>
|
</dd>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="pr.source_id">
|
|
||||||
<dt>forward-port of</dt>
|
|
||||||
<dd>
|
|
||||||
<a t-att-href="pr.source_id.url">
|
|
||||||
<span t-field="pr.source_id.display_name"/>
|
|
||||||
</a>
|
|
||||||
<span t-if="not pr.parent_id"
|
|
||||||
class="badge badge-danger user-select-none"
|
|
||||||
title="A detached PR behaves like a non-forward-port, it has to be approved via the mergebot, this is usually caused by the forward-port having been in conflict or updated.">
|
|
||||||
DETACHED (<span t-out="pr.detach_reason" style="white-space: pre-wrap;"/>)
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</t>
|
|
||||||
<t t-if="pr.forwardport_ids">
|
|
||||||
<dt>forward-ports</dt>
|
|
||||||
<dd><ul>
|
|
||||||
<t t-foreach="pr.forwardport_ids" t-as="p">
|
|
||||||
<t t-set="bgsignal"><t t-call="forwardport.pr_background"/></t>
|
|
||||||
<li t-att-class="bgsignal">
|
|
||||||
<a t-att-href="p.url"><span t-field="p.display_name"/></a>
|
|
||||||
targeting <span t-field="p.target.name"/>
|
|
||||||
</li>
|
|
||||||
</t>
|
|
||||||
</ul></dd>
|
|
||||||
</t>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -56,7 +56,8 @@ class re_matches:
|
|||||||
return repr(str(self))
|
return repr(str(self))
|
||||||
|
|
||||||
def seen(env, pr, users):
|
def seen(env, pr, users):
|
||||||
return users['user'], f'[Pull request status dashboard]({to_pr(env, pr).url}).'
|
url = to_pr(env, pr).url
|
||||||
|
return users['user'], f'[]({url})'
|
||||||
|
|
||||||
def make_basic(
|
def make_basic(
|
||||||
env,
|
env,
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
'data/runbot_merge.pull_requests.feedback.template.csv',
|
'data/runbot_merge.pull_requests.feedback.template.csv',
|
||||||
'views/res_partner.xml',
|
'views/res_partner.xml',
|
||||||
'views/runbot_merge_project.xml',
|
'views/runbot_merge_project.xml',
|
||||||
|
'views/batch.xml',
|
||||||
'views/mergebot.xml',
|
'views/mergebot.xml',
|
||||||
'views/queues.xml',
|
'views/queues.xml',
|
||||||
'views/configuration.xml',
|
'views/configuration.xml',
|
||||||
|
@ -1,13 +1,26 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import collections
|
import collections
|
||||||
|
import colorsys
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from email.utils import formatdate
|
||||||
|
from itertools import chain, product
|
||||||
|
from typing import Tuple, cast, Mapping
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
import markupsafe
|
import markupsafe
|
||||||
import werkzeug.exceptions
|
import werkzeug.exceptions
|
||||||
|
import werkzeug.wrappers
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from odoo.http import Controller, route, request
|
from odoo.http import Controller, route, request
|
||||||
|
from odoo.tools import file_open
|
||||||
|
|
||||||
LIMIT = 20
|
LIMIT = 20
|
||||||
class MergebotDashboard(Controller):
|
class MergebotDashboard(Controller):
|
||||||
@ -79,8 +92,8 @@ class MergebotDashboard(Controller):
|
|||||||
'entries': entries,
|
'entries': entries,
|
||||||
})
|
})
|
||||||
|
|
||||||
@route('/<org>/<repo>/pull/<int(min=1):pr>', auth='public', type='http', website=True, sitemap=False)
|
@route('/<org>/<repo>/pull/<int(min=1):pr><any("", ".png"):png>', auth='public', type='http', website=True, sitemap=False)
|
||||||
def pr(self, org, repo, pr):
|
def pr(self, org, repo, pr, png):
|
||||||
pr_id = request.env['runbot_merge.pull_requests'].sudo().search([
|
pr_id = request.env['runbot_merge.pull_requests'].sudo().search([
|
||||||
('repository.name', '=', f'{org}/{repo}'),
|
('repository.name', '=', f'{org}/{repo}'),
|
||||||
('number', '=', int(pr)),
|
('number', '=', int(pr)),
|
||||||
@ -90,6 +103,9 @@ class MergebotDashboard(Controller):
|
|||||||
if not pr_id.repository.group_id <= request.env.user.groups_id:
|
if not pr_id.repository.group_id <= request.env.user.groups_id:
|
||||||
raise werkzeug.exceptions.NotFound()
|
raise werkzeug.exceptions.NotFound()
|
||||||
|
|
||||||
|
if png:
|
||||||
|
return raster_render(pr_id)
|
||||||
|
|
||||||
st = {}
|
st = {}
|
||||||
if pr_id.statuses:
|
if pr_id.statuses:
|
||||||
# normalise `statuses` to map to a dict
|
# normalise `statuses` to map to a dict
|
||||||
@ -102,3 +118,218 @@ class MergebotDashboard(Controller):
|
|||||||
'merged_head': json.loads(pr_id.commits_map).get(''),
|
'merged_head': json.loads(pr_id.commits_map).get(''),
|
||||||
'statuses': st
|
'statuses': st
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def raster_render(pr):
|
||||||
|
default_headers = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Last-Modified': formatdate(),
|
||||||
|
# - anyone can cache the image, so public
|
||||||
|
# - crons run about every minute so that's how long a request is fresh
|
||||||
|
# - if the mergebot can't be contacted, allow using the stale response (no must-revalidate)
|
||||||
|
# - intermediate caches can recompress the PNG if they want (pillow is not a very good PNG generator)
|
||||||
|
# - the response is mutable even during freshness, technically (as there
|
||||||
|
# is no guarantee the freshness window lines up with the cron, plus
|
||||||
|
# some events are not cron-based)
|
||||||
|
# - maybe don't allow serving the stale image *while* revalidating?
|
||||||
|
# - allow serving a stale image for a day if the server returns 500
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-if-error=86400',
|
||||||
|
}
|
||||||
|
if if_none_match := request.httprequest.headers.get('If-None-Match'):
|
||||||
|
# just copy the existing value out if we received any
|
||||||
|
default_headers['ETag'] = if_none_match
|
||||||
|
|
||||||
|
# weak validation: check the latest modification date of all objects involved
|
||||||
|
project, repos, branches, genealogy = pr.env.ref('runbot_merge.dashboard-pre')\
|
||||||
|
._run_action_code_multi({'pr': pr})
|
||||||
|
|
||||||
|
# last-modified should be in RFC2822 format, which is what
|
||||||
|
# email.utils.formatdate does (sadly takes a timestamp but...)
|
||||||
|
last_modified = formatdate(max((
|
||||||
|
o.write_date
|
||||||
|
for o in chain(
|
||||||
|
project,
|
||||||
|
repos,
|
||||||
|
branches,
|
||||||
|
genealogy,
|
||||||
|
genealogy.all_prs | pr,
|
||||||
|
)
|
||||||
|
)).timestamp())
|
||||||
|
# The (304) response must not contain a body and must include the headers
|
||||||
|
# that would have been sent in an equivalent 200 OK response
|
||||||
|
headers = {**default_headers, 'Last-Modified': last_modified}
|
||||||
|
if request.httprequest.headers.get('If-Modified-Since') == last_modified:
|
||||||
|
return werkzeug.wrappers.Response(status=304, headers=headers)
|
||||||
|
|
||||||
|
with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf', 'rb') as f:
|
||||||
|
font = ImageFont.truetype(f, size=16, layout_engine=0)
|
||||||
|
f.seek(0)
|
||||||
|
supfont = ImageFont.truetype(f, size=10, layout_engine=0)
|
||||||
|
with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Bold.ttf', 'rb') as f:
|
||||||
|
bold = ImageFont.truetype(f, size=16, layout_engine=0)
|
||||||
|
|
||||||
|
batches = pr.env.ref('runbot_merge.dashboard-prep')._run_action_code_multi({
|
||||||
|
'pr': pr,
|
||||||
|
'repos': repos,
|
||||||
|
'branches': branches,
|
||||||
|
'genealogy': genealogy,
|
||||||
|
})
|
||||||
|
|
||||||
|
# getbbox returns (left, top, right, bottom)
|
||||||
|
|
||||||
|
rows = {b: font.getbbox(b.name)[3] for b in branches}
|
||||||
|
rows[None] = max(bold.getbbox(r.name)[3] for r in repos)
|
||||||
|
|
||||||
|
columns = {r: bold.getbbox(r.name)[2] for r in repos}
|
||||||
|
columns[None] = max(font.getbbox(b.name)[2] for b in branches)
|
||||||
|
|
||||||
|
etag = hashlib.sha256(f"(P){pr.id},{pr.repository.id},{pr.target.id}".encode())
|
||||||
|
# repos and branches should be in a consistent order so can just hash that
|
||||||
|
etag.update(''.join(f'(R){r.name}' for r in repos).encode())
|
||||||
|
etag.update(''.join(f'(T){b.name},{b.active}' for b in branches).encode())
|
||||||
|
# and product of deterministic iterations should be deterministic
|
||||||
|
for r, b in product(repos, branches):
|
||||||
|
ps = batches[r, b]
|
||||||
|
etag.update(f"(B){ps['state']},{ps['detached']},{ps['active']}".encode())
|
||||||
|
# technically label (state + blocked) does not actually impact image
|
||||||
|
# render (though subcomponents of state do) however blocked is useful
|
||||||
|
# to force an etag miss so keeping it
|
||||||
|
# TODO: blocked includes draft & merge method, maybe should change looks?
|
||||||
|
etag.update(''.join(
|
||||||
|
f"(PS){p['label']},{p['closed']},{p['number']},{p['checked']},{p['reviewed']},{p['attached']}"
|
||||||
|
for p in ps['prs']
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
w = h = 0
|
||||||
|
for p in ps['prs']:
|
||||||
|
_, _, ww, hh = font.getbbox(f" #{p['number']}")
|
||||||
|
w += ww + supfont.getbbox(' '.join(filter(None, [
|
||||||
|
'error' if p['pr'].error else '',
|
||||||
|
'' if p['checked'] else 'unchecked',
|
||||||
|
'' if p['reviewed'] else 'unreviewed',
|
||||||
|
'' if p['attached'] else 'detached',
|
||||||
|
])))[2]
|
||||||
|
h = max(hh, h)
|
||||||
|
rows[b] = max(rows.get(b, 0), h)
|
||||||
|
columns[r] = max(columns.get(r, 0), w)
|
||||||
|
|
||||||
|
etag = headers['ETag'] = base64.b32encode(etag.digest()).decode()
|
||||||
|
if if_none_match == etag:
|
||||||
|
return werkzeug.wrappers.Response(status=304, headers=headers)
|
||||||
|
|
||||||
|
pad_w, pad_h = 20, 5
|
||||||
|
image_height = sum(rows.values()) + 2 * pad_h * len(rows)
|
||||||
|
image_width = sum(columns.values()) + 2 * pad_w * len(columns)
|
||||||
|
im = Image.new("RGB", (image_width+1, image_height+1), color='white')
|
||||||
|
draw = ImageDraw.Draw(im, 'RGB')
|
||||||
|
draw.font = font
|
||||||
|
|
||||||
|
# for reasons of that being more convenient we store the bottom of the
|
||||||
|
# current row, so getting the top edge requires subtracting h
|
||||||
|
w = left = bottom = 0
|
||||||
|
for b, r in product(chain([None], branches), chain([None], repos)):
|
||||||
|
left += w
|
||||||
|
|
||||||
|
opacity = 1.0 if b is None or b.active else 0.5
|
||||||
|
background = BG['info'] if b == pr.target or r == pr.repository else BG[None]
|
||||||
|
w, h = columns[r] + 2 * pad_w, rows[b] + 2 * pad_h
|
||||||
|
|
||||||
|
if r is None: # branch cell in row
|
||||||
|
left = 0
|
||||||
|
bottom += h
|
||||||
|
if b:
|
||||||
|
draw.rectangle(
|
||||||
|
(left + 1, bottom - h + 1, left+w - 1, bottom - 1),
|
||||||
|
background,
|
||||||
|
)
|
||||||
|
draw.text(
|
||||||
|
(left + pad_w, bottom - h + pad_h),
|
||||||
|
b.name,
|
||||||
|
fill=blend(TEXT, opacity, over=background),
|
||||||
|
)
|
||||||
|
elif b is None: # repo cell in top row
|
||||||
|
draw.rectangle((left + 1, bottom - h + 1, left+w - 1, bottom - 1), background)
|
||||||
|
draw.text((left + pad_w, bottom - h + pad_h), r.name, fill=TEXT, font=bold)
|
||||||
|
# draw the bottom-right edges of the cell
|
||||||
|
draw.line([
|
||||||
|
(left, bottom), # bottom-left
|
||||||
|
(left + w, bottom), # bottom-right
|
||||||
|
(left+w, bottom-h) # top-right
|
||||||
|
], fill=(172, 176, 170))
|
||||||
|
if r is None or b is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ps = batches[r, b]
|
||||||
|
|
||||||
|
bgcolor = BG[ps['state']]
|
||||||
|
if pr in ps['pr_ids']:
|
||||||
|
bgcolor = lighten(bgcolor, by=-0.05)
|
||||||
|
background = blend(bgcolor, opacity, over=background)
|
||||||
|
draw.rectangle((left + 1, bottom - h + 1, left+w - 1, bottom - 1), background)
|
||||||
|
|
||||||
|
top = bottom - h + pad_h
|
||||||
|
offset = left + pad_w
|
||||||
|
for p in ps['prs']:
|
||||||
|
label = f"#{p['number']}"
|
||||||
|
foreground = blend((39, 110, 114), opacity, over=background)
|
||||||
|
draw.text((offset, top), label, fill=foreground)
|
||||||
|
x, _, ww, hh = font.getbbox(label)
|
||||||
|
if p['closed']:
|
||||||
|
draw.line([
|
||||||
|
(offset+x, top + hh - hh/3),
|
||||||
|
(offset+x+ww, top + hh - hh/3),
|
||||||
|
], fill=foreground)
|
||||||
|
offset += ww
|
||||||
|
if not p['attached']:
|
||||||
|
# overdraw top border to mark the detachment
|
||||||
|
draw.line([(left, bottom-h), (left+w, bottom-h)], fill=ERROR)
|
||||||
|
for attribute in filter(None, [
|
||||||
|
'error' if p['pr'].error else '',
|
||||||
|
'' if p['checked'] else 'unchecked',
|
||||||
|
'' if p['reviewed'] else 'unreviewed',
|
||||||
|
'' if p['attached'] else 'detached',
|
||||||
|
]):
|
||||||
|
label = f' {attribute}'
|
||||||
|
draw.text((offset, top), label,
|
||||||
|
fill=blend(ERROR, opacity, over=background),
|
||||||
|
font=supfont)
|
||||||
|
offset += supfont.getbbox(label)[2]
|
||||||
|
offset += math.ceil(supfont.getlength(" "))
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
im.save(buffer, 'png', optimize=True)
|
||||||
|
return werkzeug.wrappers.Response(buffer.getvalue(), headers=headers)
|
||||||
|
|
||||||
|
Color = Tuple[int, int, int]
|
||||||
|
TEXT: Color = (102, 102, 102)
|
||||||
|
ERROR: Color = (220, 53, 69)
|
||||||
|
BG: Mapping[str | None, Color] = collections.defaultdict(lambda: (255, 255, 255), {
|
||||||
|
'info': (217, 237, 247),
|
||||||
|
'success': (223, 240, 216),
|
||||||
|
'warning': (252, 248, 227),
|
||||||
|
'danger': (242, 222, 222),
|
||||||
|
})
|
||||||
|
def blend_single(c: int, over: int, opacity: float) -> int:
|
||||||
|
return round(over * (1 - opacity) + c * opacity)
|
||||||
|
|
||||||
|
def blend(color: Color, opacity: float, *, over: Color = (255, 255, 255)) -> Color:
|
||||||
|
assert 0.0 <= opacity <= 1.0
|
||||||
|
return (
|
||||||
|
blend_single(color[0], over[0], opacity),
|
||||||
|
blend_single(color[1], over[1], opacity),
|
||||||
|
blend_single(color[2], over[2], opacity),
|
||||||
|
)
|
||||||
|
|
||||||
|
def lighten(color: Color, *, by: float) -> Color:
|
||||||
|
# colorsys uses values in the range [0, 1] rather than pillow/CSS-style [0, 225]
|
||||||
|
r, g, b = tuple(c / 255 for c in color)
|
||||||
|
hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b)
|
||||||
|
|
||||||
|
# by% of the way between value and 1.0
|
||||||
|
if by >= 0: lightness += (1.0 - lightness) * by
|
||||||
|
# -by% of the way between 0 and value
|
||||||
|
else:lightness *= (1.0 + by)
|
||||||
|
|
||||||
|
return cast(Color, tuple(
|
||||||
|
round(c * 255)
|
||||||
|
for c in colorsys.hls_to_rgb(hue, lightness, saturation)
|
||||||
|
))
|
||||||
|
@ -53,7 +53,7 @@ runbot_merge.failure.approved,{pr.ping}{status!r} failed on this reviewed PR.,"N
|
|||||||
|
|
||||||
pr: pull request in question
|
pr: pull request in question
|
||||||
status: failed status"
|
status: failed status"
|
||||||
runbot_merge.pr.created,[Pull request status dashboard]({pr.url}).,"Initial comment on PR creation.
|
runbot_merge.pr.created,[]({pr.url}),"Initial comment on PR creation.
|
||||||
|
|
||||||
pr: created pr"
|
pr: created pr"
|
||||||
runbot_merge.pr.linked.not_ready,{pr.ping}linked pull request(s) {siblings} not ready. Linked PRs are not staged until all of them are ready.,"Comment when a PR is ready (approved & validated) but it is linked to other PRs which are not.
|
runbot_merge.pr.linked.not_ready,{pr.ping}linked pull request(s) {siblings} not ready. Linked PRs are not staged until all of them are ready.,"Comment when a PR is ready (approved & validated) but it is linked to other PRs which are not.
|
||||||
|
|
@ -53,6 +53,7 @@ class Batch(models.Model):
|
|||||||
_inherit = ['mail.thread']
|
_inherit = ['mail.thread']
|
||||||
_parent_store = True
|
_parent_store = True
|
||||||
|
|
||||||
|
name = fields.Char(compute="_compute_name")
|
||||||
target = fields.Many2one('runbot_merge.branch', store=True, compute='_compute_target')
|
target = fields.Many2one('runbot_merge.branch', store=True, compute='_compute_target')
|
||||||
batch_staging_ids = fields.One2many('runbot_merge.staging.batch', 'runbot_merge_batch_id')
|
batch_staging_ids = fields.One2many('runbot_merge.staging.batch', 'runbot_merge_batch_id')
|
||||||
staging_ids = fields.Many2many(
|
staging_ids = fields.Many2many(
|
||||||
@ -176,6 +177,11 @@ class Batch(models.Model):
|
|||||||
def _search_open_prs(self, operator, value):
|
def _search_open_prs(self, operator, value):
|
||||||
return [('all_prs', operator, value), ('active', '=', True)]
|
return [('all_prs', operator, value), ('active', '=', True)]
|
||||||
|
|
||||||
|
@api.depends("prs.label")
|
||||||
|
def _compute_name(self):
|
||||||
|
for batch in self:
|
||||||
|
batch.name = batch.prs[:1].label or batch.all_prs[:1].label
|
||||||
|
|
||||||
@api.depends("all_prs.target")
|
@api.depends("all_prs.target")
|
||||||
def _compute_target(self):
|
def _compute_target(self):
|
||||||
for batch in self:
|
for batch in self:
|
||||||
@ -190,7 +196,6 @@ class Batch(models.Model):
|
|||||||
else:
|
else:
|
||||||
batch.target = False
|
batch.target = False
|
||||||
|
|
||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
"merge_date",
|
"merge_date",
|
||||||
"prs.error", "prs.draft", "prs.squash", "prs.merge_method",
|
"prs.error", "prs.draft", "prs.squash", "prs.merge_method",
|
||||||
|
@ -14,27 +14,30 @@ h1, h2, h3, h4, h5, h6{
|
|||||||
margin-bottom: 0.33em;
|
margin-bottom: 0.33em;
|
||||||
}
|
}
|
||||||
h5 { font-size: 1em; }
|
h5 { font-size: 1em; }
|
||||||
.bg-success, .bg-info, .bg-warning, .bg-danger, .bg-gray-lighter {
|
.bg-success, .bg-info, .bg-warning, .bg-danger, .bg-gray-lighter,
|
||||||
|
.table-success, .table-info, .table-warning, .table-danger {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.dropdown-item, .dropdown-menu, .dropdown-menu a {
|
.dropdown-item, .dropdown-menu, .dropdown-menu a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.bg-success {
|
|
||||||
background-color: #dff0d8 !important;
|
$mergebot-colors: ("success": #dff0d8, "danger": #f2dede, "warning": #fcf8e3, "info": #d9edf7);
|
||||||
|
@each $category, $color in $mergebot-colors {
|
||||||
|
.bg-#{$category} {
|
||||||
|
background-color: $color !important;
|
||||||
|
}
|
||||||
|
.table-#{$category} {
|
||||||
|
background-color: $color !important;
|
||||||
|
&.table-active {
|
||||||
|
background-color: scale-color($color, $lightness: -5%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.bg-unmerged {
|
.bg-unmerged {
|
||||||
background-color: #dcefe8 !important
|
background-color: #f8f0e3 !important
|
||||||
}
|
|
||||||
.bg-info {
|
|
||||||
background-color: #d9edf7 !important;
|
|
||||||
}
|
|
||||||
.bg-warning {
|
|
||||||
background-color: #fcf8e3 !important;
|
|
||||||
}
|
|
||||||
.bg-danger {
|
|
||||||
background-color: #f2dede !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-inline {
|
.list-inline {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@ -121,3 +124,16 @@ dl.runbot-merge-fields {
|
|||||||
// works better for the left edge of the *box*
|
// works better for the left edge of the *box*
|
||||||
@extend .border-left;
|
@extend .border-left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// batches sequence table in PR dashboard: mostly uses (customised) bootstrap
|
||||||
|
// but some of the style is bespoke because inline styles don't work well with
|
||||||
|
// CSP
|
||||||
|
.closed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
tr.inactive {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
td.detached {
|
||||||
|
border-top: 2px solid map-get($theme-colors, "danger");
|
||||||
|
}
|
||||||
|
@ -53,7 +53,6 @@ def test_trivial_flow(env, repo, page, users, config):
|
|||||||
)) == {
|
)) == {
|
||||||
'label': f"{config['github']['owner']}:other",
|
'label': f"{config['github']['owner']}:other",
|
||||||
'head': c1,
|
'head': c1,
|
||||||
'target': 'master',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with repo:
|
with repo:
|
||||||
|
@ -59,12 +59,9 @@ def test_existing_pr_disabled_branch(env, project, make_repo, setreviewers, conf
|
|||||||
assert staging_id.reason == "Target branch deactivated by 'admin'."
|
assert staging_id.reason == "Target branch deactivated by 'admin'."
|
||||||
|
|
||||||
p = pr_page(page, pr)
|
p = pr_page(page, pr)
|
||||||
target = dict(zip(
|
[target] = p.cssselect('table tr.bg-info')
|
||||||
(e.text for e in p.cssselect('dl.runbot-merge-fields dt')),
|
assert 'inactive' in target.classes
|
||||||
(p.cssselect('dl.runbot-merge-fields dd'))
|
assert target[0].text_content() == "other"
|
||||||
))['target']
|
|
||||||
assert target.text_content() == 'other (inactive)'
|
|
||||||
assert target.get('class') == 'text-muted bg-warning'
|
|
||||||
|
|
||||||
assert pr.comments == [
|
assert pr.comments == [
|
||||||
(users['reviewer'], "hansen r+"),
|
(users['reviewer'], "hansen r+"),
|
||||||
|
@ -1109,11 +1109,9 @@ def test_multi_project(env, make_repo, setreviewers, users, config,
|
|||||||
|
|
||||||
assert pr1.comments == [
|
assert pr1.comments == [
|
||||||
(users['reviewer'], 'hansen r+'),
|
(users['reviewer'], 'hansen r+'),
|
||||||
(users['user'], f'[Pull request status dashboard]({pr1_id.url}).'),
|
seen(env, pr1, users),
|
||||||
]
|
|
||||||
assert pr2.comments == [
|
|
||||||
(users['user'], f'[Pull request status dashboard]({pr2_id.url}).'),
|
|
||||||
]
|
]
|
||||||
|
assert pr2.comments == [seen(env, pr2, users)]
|
||||||
|
|
||||||
def test_freeze_complete(env, project, repo_a, repo_b, repo_c, users, config):
|
def test_freeze_complete(env, project, repo_a, repo_b, repo_c, users, config):
|
||||||
""" Tests the freeze wizard feature (aside from the UI):
|
""" Tests the freeze wizard feature (aside from the UI):
|
||||||
|
59
runbot_merge/views/batch.xml
Normal file
59
runbot_merge/views/batch.xml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="runbot_merge_batch_form" model="ir.ui.view">
|
||||||
|
<field name="name">Batch form</field>
|
||||||
|
<field name="model">runbot_merge.batch</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title"><h1><field name="name"/></h1></div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="target"/>
|
||||||
|
<field name="merge_date"/>
|
||||||
|
<field name="priority" attrs="{'invisible': [('merge_date', '!=', False)]}"/>
|
||||||
|
<field name="skipchecks" widget="boolean_toggle" attrs="{'invisible': [('merge_date', '!=', False)]}"/>
|
||||||
|
<field name="cancel_staging" widget="boolean_toggle" attrs="{'invisible': [('merge_date', '!=', False)]}"/>
|
||||||
|
<field name="fw_policy"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="blocked"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Pull Requests">
|
||||||
|
<group colspan="4">
|
||||||
|
<field name="all_prs" nolabel="1" readonly="1">
|
||||||
|
<tree>
|
||||||
|
<field name="display_name"/>
|
||||||
|
<field name="repository"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Genealogy">
|
||||||
|
<group colspan="4">
|
||||||
|
<field name="genealogy_ids" nolabel="1" readonly="1">
|
||||||
|
<tree decoration-muted="id == parent.id">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="target"/>
|
||||||
|
<field name="all_prs" widget="many2many_tags"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Stagings">
|
||||||
|
<group colspan="4">
|
||||||
|
<field name="staging_ids" nolabel="1" readonly="1">
|
||||||
|
<tree>
|
||||||
|
<field name="staged_at"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="reason"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
@ -131,49 +131,41 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<h2>
|
<h2>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
<span attrs="{'invisible': [('blocked', '=', False)]}">
|
<span attrs="{'invisible': ['|', ('state', '=', 'merged'), ('blocked', '=', False)]}">
|
||||||
(<field name="blocked"/>)
|
(blocked: <field name="blocked"/>)
|
||||||
</span>
|
</span>
|
||||||
|
<span attrs="{'invisible': [('state', '!=', 'merged')]}">
|
||||||
|
(<field name="merge_date"/>)
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- massively impactful status items -->
|
|
||||||
<group>
|
|
||||||
<group>
|
|
||||||
<field name="skipchecks" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="cancel_staging" widget="boolean_toggle"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<!-- main PR metadata -->
|
<!-- main PR metadata -->
|
||||||
<group>
|
<group>
|
||||||
<group name="metadata">
|
<group name="metadata" colspan="2">
|
||||||
|
<field name="batch_id"/>
|
||||||
<field name="target"/>
|
<field name="target"/>
|
||||||
<field name="label"/>
|
|
||||||
<field name="author"/>
|
<field name="author"/>
|
||||||
<field name="head"/>
|
<field name="head"/>
|
||||||
</group>
|
</group>
|
||||||
<group name="merging">
|
<group name="merging">
|
||||||
<field name="priority"/>
|
|
||||||
<field name="merge_method"/>
|
<field name="merge_method"/>
|
||||||
<field name="squash"/>
|
<field name="squash"/>
|
||||||
<field name="draft"/>
|
<field name="draft"/>
|
||||||
<!-- <field string="Up To" name="limit_id"/>-->
|
<field name="priority"/>
|
||||||
<!-- <field string="Forward-Port Policy" name="fw_policy"/>-->
|
<field name="skipchecks" widget="boolean_toggle"/>
|
||||||
|
<field name="cancel_staging" widget="boolean_toggle"/>
|
||||||
|
<field name="limit_id"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<!-- influencers -->
|
<!-- influencers -->
|
||||||
<group string="State">
|
<group string="State">
|
||||||
<group>
|
<group colspan="4">
|
||||||
<field name="merge_date"/>
|
|
||||||
<field name="reviewed_by"/>
|
<field name="reviewed_by"/>
|
||||||
<field name="error"/>
|
<field name="error"/>
|
||||||
<field name="closed"/>
|
<field name="closed"/>
|
||||||
</group>
|
|
||||||
<group name="status">
|
|
||||||
<field name="status"/>
|
<field name="status"/>
|
||||||
<field name="statuses"/>
|
|
||||||
<field name="overrides"/>
|
<field name="overrides"/>
|
||||||
|
<field name="statuses"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="Forward Porting">
|
<group string="Forward Porting">
|
||||||
@ -290,6 +282,15 @@
|
|||||||
</field>
|
</field>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Batches">
|
||||||
|
<field name="batch_ids" colspan="4" nolabel="1" readonly="1">
|
||||||
|
<tree>
|
||||||
|
<button type="object" name="get_formview_action" icon="fa-external-link"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="prs" widget="many2many_tags"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
<group string="PRs">
|
<group string="PRs">
|
||||||
<field name="pr_ids" colspan="4" nolabel="1" readonly="1">
|
<field name="pr_ids" colspan="4" nolabel="1" readonly="1">
|
||||||
<tree>
|
<tree>
|
||||||
@ -300,14 +301,6 @@
|
|||||||
</tree>
|
</tree>
|
||||||
</field>
|
</field>
|
||||||
</group>
|
</group>
|
||||||
<group string="Batches">
|
|
||||||
<field name="batch_ids" colspan="4" nolabel="1" readonly="1">
|
|
||||||
<tree>
|
|
||||||
<button type="object" name="get_formview_action" icon="fa-external-link"/>
|
|
||||||
<field name="prs" widget="many2many_tags"/>
|
|
||||||
</tree>
|
|
||||||
</field>
|
|
||||||
</group>
|
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
|
@ -412,17 +412,178 @@
|
|||||||
<t t-else="">open</t>
|
<t t-else="">open</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-call="runbot_merge.view_pull_request_info_{{tmpl.strip()}}"/>
|
<t t-call="runbot_merge.view_pull_request_info_{{tmpl.strip()}}"/>
|
||||||
<t t-set="target_cls" t-value="None if pr.target.active else 'text-muted bg-warning'"/>
|
|
||||||
<dl class="runbot-merge-fields">
|
<dl class="runbot-merge-fields">
|
||||||
<dt>label</dt>
|
<dt>label</dt>
|
||||||
<dd><span t-field="pr.label"/></dd>
|
<dd><span t-field="pr.label"/></dd>
|
||||||
<dt>head</dt>
|
<dt>head</dt>
|
||||||
<dd><a t-attf-href="{{pr.github_url}}/commits/{{pr.head}}"><span t-field="pr.head"/></a></dd>
|
<dd><a t-attf-href="{{pr.github_url}}/commits/{{pr.head}}"><span t-field="pr.head"/></a></dd>
|
||||||
<dt t-att-class="target_cls">target</dt>
|
|
||||||
<dd t-att-class="target_cls"><span t-field="pr.target"/></dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
<t t-call="runbot_merge.dashboard-table"/>
|
||||||
<p t-field="pr.message"/>
|
<p t-field="pr.message"/>
|
||||||
</div></div>
|
</div></div>
|
||||||
</t>
|
</t>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<record id="dashboard-pre" model="ir.actions.server">
|
||||||
|
<field name="name">Preparation for the preparation of the PR dashboard content</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="model_id" ref="base.model_ir_qweb"/>
|
||||||
|
<field name="code"><![CDATA[
|
||||||
|
project = pr.repository.project_id
|
||||||
|
genealogy = pr.batch_id.genealogy_ids
|
||||||
|
repos = project.repo_ids & genealogy.all_prs.repository
|
||||||
|
targets = genealogy.all_prs.target
|
||||||
|
if not genealogy:
|
||||||
|
# if a PR is closed, it may not have a batch to get a genealogy from,
|
||||||
|
# in which case it's just a sole soul drifting in the deep dark
|
||||||
|
branches = pr.target
|
||||||
|
repos = pr.repository
|
||||||
|
elif all(p.state in ('merged', 'closed') for p in genealogy[-1].all_prs):
|
||||||
|
branches = (project.branch_ids & targets)[::-1]
|
||||||
|
else:
|
||||||
|
# if the tip of the genealogy is not closed, extend to the furthest limit,
|
||||||
|
# keeping branches which are active or have an associated batch / PR
|
||||||
|
limit = genealogy.prs.limit_id.sorted(lambda b: (b.sequence, b.name))
|
||||||
|
limit_high = project.branch_ids.ids.index(limit.id) if limit else None
|
||||||
|
limit = targets.sorted(lambda b: (b.sequence, b.name))[-1]
|
||||||
|
limit_low = project.branch_ids.ids.index(limit.id)
|
||||||
|
branches = project.branch_ids[limit_high:limit_low+1].filtered(lambda b: b.active or b in targets)[::-1]
|
||||||
|
|
||||||
|
action = (project, repos, branches, genealogy)
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="dashboard-prep" model="ir.actions.server">
|
||||||
|
<field name="name">Preparation of the PR dashboard content</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="model_id" ref="base.model_ir_qweb"/>
|
||||||
|
<field name="code"><![CDATA[
|
||||||
|
batches = {}
|
||||||
|
for branch in branches:
|
||||||
|
# FIXME: batches with inconsistent targets?
|
||||||
|
if genealogy:
|
||||||
|
prs_batch = genealogy.filtered(lambda b: b.target == branch).all_prs
|
||||||
|
else:
|
||||||
|
prs_batch = pr
|
||||||
|
for repo in repos:
|
||||||
|
prs = prs_batch.filtered(lambda p: p.repository == repo)
|
||||||
|
st = 0
|
||||||
|
detached = False
|
||||||
|
pr_fmt = []
|
||||||
|
for p in prs:
|
||||||
|
st |= (bool(p.error) << 2 | (p.state == 'merged') << 1 | bool(p.blocked) << 0)
|
||||||
|
|
||||||
|
done = p.state in ('closed', 'merged')
|
||||||
|
# this will hide the detachment signal when the PRs are merged/closed, cleaner but less correct?
|
||||||
|
detached = detached or bool(p.source_id and not p.parent_id and not done)
|
||||||
|
label = p.state
|
||||||
|
if p.blocked:
|
||||||
|
label = "%s, %s" % (label, p.blocked)
|
||||||
|
pr_fmt.append({
|
||||||
|
'pr': p,
|
||||||
|
'number': p.number,
|
||||||
|
'label': label,
|
||||||
|
'closed': p.closed,
|
||||||
|
'backend_url': "/web#view_type=form&model=runbot_merge.pull_requests&id=%d" % p.id,
|
||||||
|
'github_url': p.github_url,
|
||||||
|
'checked': done or p.status == 'success',
|
||||||
|
'reviewed': done or bool(p.reviewed_by),
|
||||||
|
'attached': done or p.parent_id or not p.source_id,
|
||||||
|
})
|
||||||
|
state = None
|
||||||
|
for i, s in zip(range(2, -1, -1), ['danger', 'success', 'warning']):
|
||||||
|
if st & (1 << i):
|
||||||
|
state = s
|
||||||
|
break
|
||||||
|
|
||||||
|
batches[repo, branch] = {
|
||||||
|
'active': pr in prs,
|
||||||
|
'detached': detached,
|
||||||
|
'state': state,
|
||||||
|
'prs': pr_fmt,
|
||||||
|
'pr_ids': prs,
|
||||||
|
}
|
||||||
|
|
||||||
|
action = batches
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
<template id="dashboard-table">
|
||||||
|
<t t-set="pre" t-value="pr.env.ref('runbot_merge.dashboard-pre').sudo()._run_action_code_multi({'pr': pr})"/>
|
||||||
|
<t t-set="repos" t-value="pre[1]"/>
|
||||||
|
<t t-set="branches" t-value="pre[2]"/>
|
||||||
|
<t t-set="batches" t-value="env.ref('runbot_merge.dashboard-prep').sudo()._run_action_code_multi({
|
||||||
|
'pr': pr,
|
||||||
|
'repos': repos,
|
||||||
|
'branches': branches,
|
||||||
|
'genealogy': pre[3],
|
||||||
|
})"/>
|
||||||
|
<table class="table table-bordered table-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col/>
|
||||||
|
<col t-foreach="repos" t-as="repo"
|
||||||
|
t-att-class="'bg-info' if repo == pr.repository else None"
|
||||||
|
/>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th/>
|
||||||
|
<th t-foreach="repos" t-as="repo">
|
||||||
|
<t t-out="repo.name"/>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!--
|
||||||
|
table-info looks like shit (possibly because no odoo styling so use bg-info
|
||||||
|
text-muted doesn't do anything, so set some opacity
|
||||||
|
-->
|
||||||
|
<tr t-foreach="branches" t-as="branch"
|
||||||
|
t-att-title="None if branch.active else 'branch is disabled'"
|
||||||
|
t-attf-class="{{
|
||||||
|
'bg-info' if branch == pr.target else ''
|
||||||
|
}} {{
|
||||||
|
'inactive' if not branch.active else ''
|
||||||
|
}}">
|
||||||
|
<td t-out="branch.name"/>
|
||||||
|
<t t-foreach="repos" t-as="repo">
|
||||||
|
<t t-set="ps" t-value="batches[repo, branch]"/>
|
||||||
|
<t t-set="stateclass" t-value="ps['state'] and 'table-'+ps['state']"/>
|
||||||
|
<t t-set="detached" t-value="ps['detached']"/>
|
||||||
|
<td t-if="ps['prs']"
|
||||||
|
t-att-title="'detached' if detached else None"
|
||||||
|
t-attf-class="{{
|
||||||
|
'table-active' if ps['active'] else ''
|
||||||
|
}} {{
|
||||||
|
'detached' if detached else ''
|
||||||
|
}}{{stateclass}}">
|
||||||
|
<!--
|
||||||
|
there should be only one PR per (repo, target) but
|
||||||
|
that's not always the case
|
||||||
|
-->
|
||||||
|
<span t-foreach="ps['prs']" t-as="p"
|
||||||
|
t-att-title="p['label']"
|
||||||
|
t-att-class="'closed' if p['closed'] else None">
|
||||||
|
<a t-attf-href="/{{repo.name}}/pull/{{p['number']}}">#<t t-out="p['number']"/></a>
|
||||||
|
<a t-attf-class="fa fa-brands fa-github"
|
||||||
|
title="Open on Github"
|
||||||
|
t-att-href="p['github_url']"
|
||||||
|
/>
|
||||||
|
<a groups="base.group_user"
|
||||||
|
title="Open in Backend"
|
||||||
|
t-attf-class="fa fa-external-link"
|
||||||
|
t-att-href="p['backend_url']"
|
||||||
|
/>
|
||||||
|
<sup t-if="not p['checked']" class="text-danger">unchecked</sup>
|
||||||
|
<sup t-if="not p['reviewed']" class="text-danger">unreviewed</sup>
|
||||||
|
<sup t-if="not p['attached']"
|
||||||
|
t-attf-title="detached: {{p['pr'].detach_reason}}"
|
||||||
|
class="text-warning fa fa-unlink"/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td t-else=""/>
|
||||||
|
</t>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
Loading…
Reference in New Issue
Block a user