[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:
Xavier Morel 2024-03-05 12:59:58 +01:00
parent 3191c44459
commit 232aa271b0
13 changed files with 522 additions and 86 deletions

View File

@ -136,31 +136,6 @@
t-attf-title="@{{pr.reviewed_by.github_login}}"/>
</dd>
</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>
</template>

View File

@ -56,7 +56,8 @@ class re_matches:
return repr(str(self))
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'[![Pull request status dashboard]({url}.png)]({url})'
def make_basic(
env,

View File

@ -12,6 +12,7 @@
'data/runbot_merge.pull_requests.feedback.template.csv',
'views/res_partner.xml',
'views/runbot_merge_project.xml',
'views/batch.xml',
'views/mergebot.xml',
'views/queues.xml',
'views/configuration.xml',

View File

@ -1,13 +1,26 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import base64
import collections
import colorsys
import hashlib
import io
import json
import math
import pathlib
from email.utils import formatdate
from itertools import chain, product
from typing import Tuple, cast, Mapping
import markdown
import markupsafe
import werkzeug.exceptions
import werkzeug.wrappers
from PIL import Image, ImageDraw, ImageFont
from odoo.http import Controller, route, request
from odoo.tools import file_open
LIMIT = 20
class MergebotDashboard(Controller):
@ -79,8 +92,8 @@ class MergebotDashboard(Controller):
'entries': entries,
})
@route('/<org>/<repo>/pull/<int(min=1):pr>', auth='public', type='http', website=True, sitemap=False)
def pr(self, org, repo, pr):
@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, png):
pr_id = request.env['runbot_merge.pull_requests'].sudo().search([
('repository.name', '=', f'{org}/{repo}'),
('number', '=', int(pr)),
@ -90,6 +103,9 @@ class MergebotDashboard(Controller):
if not pr_id.repository.group_id <= request.env.user.groups_id:
raise werkzeug.exceptions.NotFound()
if png:
return raster_render(pr_id)
st = {}
if pr_id.statuses:
# normalise `statuses` to map to a dict
@ -102,3 +118,218 @@ class MergebotDashboard(Controller):
'merged_head': json.loads(pr_id.commits_map).get(''),
'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)
))

View File

@ -53,7 +53,7 @@ runbot_merge.failure.approved,{pr.ping}{status!r} failed on this reviewed PR.,"N
pr: pull request in question
status: failed status"
runbot_merge.pr.created,[Pull request status dashboard]({pr.url}).,"Initial comment on PR creation.
runbot_merge.pr.created,[![Pull request status dashboard]({pr.url}.png)]({pr.url}),"Initial comment on PR creation.
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.

1 id template help
53
54
55
56
57
58
59

View File

@ -53,6 +53,7 @@ class Batch(models.Model):
_inherit = ['mail.thread']
_parent_store = True
name = fields.Char(compute="_compute_name")
target = fields.Many2one('runbot_merge.branch', store=True, compute='_compute_target')
batch_staging_ids = fields.One2many('runbot_merge.staging.batch', 'runbot_merge_batch_id')
staging_ids = fields.Many2many(
@ -176,6 +177,11 @@ class Batch(models.Model):
def _search_open_prs(self, operator, value):
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")
def _compute_target(self):
for batch in self:
@ -190,7 +196,6 @@ class Batch(models.Model):
else:
batch.target = False
@api.depends(
"merge_date",
"prs.error", "prs.draft", "prs.squash", "prs.merge_method",

View File

@ -14,27 +14,30 @@ h1, h2, h3, h4, h5, h6{
margin-bottom: 0.33em;
}
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;
}
.dropdown-item, .dropdown-menu, .dropdown-menu a {
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 {
background-color: #dcefe8 !important
}
.bg-info {
background-color: #d9edf7 !important;
}
.bg-warning {
background-color: #fcf8e3 !important;
}
.bg-danger {
background-color: #f2dede !important;
background-color: #f8f0e3 !important
}
.list-inline {
margin-bottom: 10px;
}
@ -121,3 +124,16 @@ dl.runbot-merge-fields {
// works better for the left edge of the *box*
@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");
}

View File

@ -53,7 +53,6 @@ def test_trivial_flow(env, repo, page, users, config):
)) == {
'label': f"{config['github']['owner']}:other",
'head': c1,
'target': 'master',
}
with repo:

View File

@ -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'."
p = pr_page(page, pr)
target = dict(zip(
(e.text for e in p.cssselect('dl.runbot-merge-fields dt')),
(p.cssselect('dl.runbot-merge-fields dd'))
))['target']
assert target.text_content() == 'other (inactive)'
assert target.get('class') == 'text-muted bg-warning'
[target] = p.cssselect('table tr.bg-info')
assert 'inactive' in target.classes
assert target[0].text_content() == "other"
assert pr.comments == [
(users['reviewer'], "hansen r+"),

View File

@ -1109,11 +1109,9 @@ def test_multi_project(env, make_repo, setreviewers, users, config,
assert pr1.comments == [
(users['reviewer'], 'hansen r+'),
(users['user'], f'[Pull request status dashboard]({pr1_id.url}).'),
]
assert pr2.comments == [
(users['user'], f'[Pull request status dashboard]({pr2_id.url}).'),
seen(env, pr1, users),
]
assert pr2.comments == [seen(env, pr2, users)]
def test_freeze_complete(env, project, repo_a, repo_b, repo_c, users, config):
""" Tests the freeze wizard feature (aside from the UI):

View 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>

View File

@ -131,49 +131,41 @@
</h1>
<h2>
<field name="state"/>
<span attrs="{'invisible': [('blocked', '=', False)]}">
(<field name="blocked"/>)
<span attrs="{'invisible': ['|', ('state', '=', 'merged'), ('blocked', '=', False)]}">
(blocked: <field name="blocked"/>)
</span>
<span attrs="{'invisible': [('state', '!=', 'merged')]}">
(<field name="merge_date"/>)
</span>
</h2>
</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 -->
<group>
<group name="metadata">
<group name="metadata" colspan="2">
<field name="batch_id"/>
<field name="target"/>
<field name="label"/>
<field name="author"/>
<field name="head"/>
</group>
<group name="merging">
<field name="priority"/>
<field name="merge_method"/>
<field name="squash"/>
<field name="draft"/>
<!-- <field string="Up To" name="limit_id"/>-->
<!-- <field string="Forward-Port Policy" name="fw_policy"/>-->
<field name="priority"/>
<field name="skipchecks" widget="boolean_toggle"/>
<field name="cancel_staging" widget="boolean_toggle"/>
<field name="limit_id"/>
</group>
</group>
<!-- influencers -->
<group string="State">
<group>
<field name="merge_date"/>
<group colspan="4">
<field name="reviewed_by"/>
<field name="error"/>
<field name="closed"/>
</group>
<group name="status">
<field name="status"/>
<field name="statuses"/>
<field name="overrides"/>
<field name="statuses"/>
</group>
</group>
<group string="Forward Porting">
@ -290,6 +282,15 @@
</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="name"/>
<field name="prs" widget="many2many_tags"/>
</tree>
</field>
</group>
<group string="PRs">
<field name="pr_ids" colspan="4" nolabel="1" readonly="1">
<tree>
@ -300,14 +301,6 @@
</tree>
</field>
</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>
</form>
</field>

View File

@ -412,17 +412,178 @@
<t t-else="">open</t>
</t>
<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">
<dt>label</dt>
<dd><span t-field="pr.label"/></dd>
<dt>head</dt>
<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>
<t t-call="runbot_merge.dashboard-table"/>
<p t-field="pr.message"/>
</div></div>
</t>
</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>