mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 15:35:46 +07:00
[IMP] runbot_merge: show batch inconsistency in PR dash picture
Extract current table generation into a separate function, add an other function to render an alert / list of PR targets if the batch is not consistent. This means an extra pass on the table contents to precompute the image size, but we can delay loading fonts until after etag computation which might be a bigger gain all things considered: there aren't many cells in most PR tables, but fonts are rather expensive to load (I should probably load them at import and cache them in the module...)
This commit is contained in:
parent
bca8adbdc4
commit
6cc9a6ca11
@ -170,13 +170,6 @@ def raster_render(pr):
|
||||
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=13, 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,
|
||||
@ -184,31 +177,48 @@ def raster_render(pr):
|
||||
'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())
|
||||
etag = hashlib.sha256(f"(P){pr.id},{pr.repository.id},{pr.target.id},{pr.batch_id.blocked}".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
|
||||
|
||||
etag.update(f"(B){ps['state']},{ps['detached']},{ps['active']}".encode())
|
||||
etag.update(''.join(
|
||||
f"(PS){p['label']},{p['closed']},{p['number']},{p['checked']},{p['reviewed']},{p['attached']},{p['pr'].staging_id.id}"
|
||||
for p in ps['prs']
|
||||
).encode())
|
||||
|
||||
etag = headers['ETag'] = base64.b32encode(etag.digest()).decode()
|
||||
if if_none_match == etag:
|
||||
return werkzeug.wrappers.Response(status=304, headers=headers)
|
||||
|
||||
if not pr.batch_id.target:
|
||||
im = render_inconsistent_batch(pr.batch_id)
|
||||
else:
|
||||
im = render_full_table(pr, branches, repos, batches)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
im.save(buffer, 'png', optimize=True)
|
||||
return werkzeug.wrappers.Response(buffer.getvalue(), headers=headers)
|
||||
|
||||
|
||||
def render_full_table(pr, branches, repos, batches):
|
||||
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=13, 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)
|
||||
# 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)
|
||||
for r, b in product(repos, branches):
|
||||
ps = batches[r, b]
|
||||
w = h = 0
|
||||
for p in ps['prs']:
|
||||
_, _, ww, hh = font.getbbox(f" #{p['number']}")
|
||||
@ -223,17 +233,12 @@ def raster_render(pr):
|
||||
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
|
||||
@ -244,12 +249,12 @@ def raster_render(pr):
|
||||
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
|
||||
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),
|
||||
(left + 1, bottom - h + 1, left + w - 1, bottom - 1),
|
||||
background,
|
||||
)
|
||||
draw.text(
|
||||
@ -257,14 +262,14 @@ def raster_render(pr):
|
||||
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)
|
||||
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
|
||||
(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
|
||||
@ -275,7 +280,7 @@ def raster_render(pr):
|
||||
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)
|
||||
draw.rectangle((left + 1, bottom - h + 1, left + w - 1, bottom - 1), background)
|
||||
|
||||
top = bottom - h + pad_h
|
||||
offset = left + pad_w
|
||||
@ -286,13 +291,13 @@ def raster_render(pr):
|
||||
x, _, ww, hh = font.getbbox(label)
|
||||
if p['closed']:
|
||||
draw.line([
|
||||
(offset+x, top + hh - hh/3),
|
||||
(offset+x+ww, top + hh - hh/3),
|
||||
(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)
|
||||
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 'missing statuses',
|
||||
@ -300,17 +305,43 @@ def raster_render(pr):
|
||||
'' if p['attached'] else 'detached',
|
||||
'staged' if p['pr'].staging_id else 'ready' if p['pr']._ready else ''
|
||||
]):
|
||||
label = f' {attribute}'
|
||||
color = SUCCESS if attribute in ('staged', 'ready') else ERROR
|
||||
label = f' {attribute}'
|
||||
draw.text((offset, top), label,
|
||||
fill=blend(color, 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)
|
||||
return im
|
||||
|
||||
|
||||
def render_inconsistent_batch(batch):
|
||||
"""If a batch has inconsistent targets, just point out the inconsistency by
|
||||
listing the PR and targets
|
||||
"""
|
||||
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)
|
||||
|
||||
im = Image.new("RGB", (4000, 4000), color=BG['danger'])
|
||||
w = h = 0
|
||||
def draw(label, draw=ImageDraw.Draw(im)):
|
||||
nonlocal w, h
|
||||
|
||||
draw.text((0, h), label, fill=blend(ERROR, 1.0, over=BG['danger']), font=font)
|
||||
|
||||
_, _, ww, hh = font.getbbox(label)
|
||||
w = max(w, ww)
|
||||
h += hh
|
||||
|
||||
draw(" Inconsistent targets:")
|
||||
for p in batch.prs:
|
||||
draw(f" • {p.display_name} has target '{p.target.name}'")
|
||||
draw(" To resolve, either retarget or close the mis-targeted pull request(s).")
|
||||
|
||||
return im.crop((0, 0, w+10, h+5))
|
||||
|
||||
|
||||
|
||||
Color = Tuple[int, int, int]
|
||||
TEXT: Color = (102, 102, 102)
|
||||
@ -322,6 +353,15 @@ BG: Mapping[str | None, Color] = collections.defaultdict(lambda: (255, 255, 255)
|
||||
'warning': (252, 248, 227),
|
||||
'danger': (242, 222, 222),
|
||||
})
|
||||
|
||||
|
||||
CHECK_MARK = "\uf00c"
|
||||
CROSS = "\uf00d"
|
||||
BOX_EMPTY = "\uf096"
|
||||
DARK_BOX = "\uf0c8"
|
||||
DARK_CHECK = "\uf14a"
|
||||
|
||||
|
||||
def blend_single(c: int, over: int, opacity: float) -> int:
|
||||
return round(over * (1 - opacity) + c * opacity)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user