blob: 9e704762a3e5aaa9f403ca80fc77dd9fb6f42d42 [file] [log] [blame]
# -*- coding: utf-8 -*-
# Copyright 2019 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from recipe_engine import recipe_api
from PB.go.chromium.org.luci.buildbucket.proto import (
builds_service as builds_service_pb2,
)
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
class BuildbucketUtilApi(recipe_api.RecipeApi):
"""Module for high-level buildbucket utilities specific to Fuchsia infra."""
# The maximum number of bytes allowed in a Buildbucket build's summary
# markdown.
MAX_SUMMARY_SIZE = 4000
@property
def id(self):
"""A unique string identifier for the current build."""
if self.m.led.launched_by_led:
return self.m.led.run_id
else:
return str(self.m.buildbucket.build.id)
@property
def is_tryjob(self):
"""Whether the current build is running as a tryjob."""
bucket_name = self.m.buildbucket.build.builder.bucket
return bucket_name.endswith("try")
@property
def build_url(self):
if self.m.led.launched_by_led:
return "https://ci.chromium.org/swarming/task/%s?server=%s" % (
self.m.swarming.task_id,
self.m.buildbucket.build.infra.swarming.hostname,
)
else:
return "https://ci.chromium.org/b/%d" % self.m.buildbucket.build.id
def full_builder_name(self, builder=None):
builder = builder or self.m.buildbucket.build.builder
return "/".join((builder.project, builder.bucket, builder.builder))
def build_status(self, exc):
"""Get corresponding build status for an exception."""
if isinstance(exc, self.m.step.InfraFailure):
return common_pb2.INFRA_FAILURE
if isinstance(exc, self.m.step.StepFailure):
return common_pb2.FAILURE
return common_pb2.INFRA_FAILURE
def last_build(
self, project=None, bucket=None, builder=None, fields=None, status=None
):
"""Returns the build proto for a builder's most recent successful build.
Returns None if no successful builds could be found.
Args:
project (str): LUCI project. If None, defaults to current build's
project.
bucket (str): BuildBucket bucket. If None, defaults to current build's
bucket.
builder (str): BuildBucket builder. If None, defaults to current
build's builder name.
fields (seq of str): BuildBucket Build proto message fields to include in
the result. If None, defaults to api.buildbucket.DEFAULT_FIELDS.
status (common_pb2.Status enum member): If set, will return the
last build with this status.
"""
predicate = builds_service_pb2.BuildPredicate()
predicate.builder.project = project or self.m.buildbucket.build.builder.project
predicate.builder.bucket = bucket or self.m.buildbucket.build.builder.bucket
predicate.builder.builder = builder or self.m.buildbucket.build.builder.builder
if status is not None:
predicate.status = status
fields = fields or self.m.buildbucket.DEFAULT_FIELDS
builds = self.m.buildbucket.search(predicate, limit=1, fields=fields)
if not builds:
return None
assert len(builds) == 1
return builds[0]
def summary_message(
self,
raw_text,
truncation_message,
max_line_length=200,
truncate_length=None,
escape_markdown=True,
):
"""Return a `summary_markdown`-friendly message from raw text.
Args:
raw_text (str): Raw text to convert to summary message.
truncation_message (str): Message to attach to end of summary message
if the message was truncated.
max_line_length (int): Truncate lines longer than this value.
truncate_length (int): Maximum length of the summary message.
escape_markdown (bool): Whether to surround the summary in triple
backticks, causing it to render as raw monospace text in the
LUCI UI.
"""
if not truncate_length:
truncate_length = (
self.MAX_SUMMARY_SIZE
# Include an extra buffer so that a parent build can expose its
# child's summary as its own summary, with a prefix.
- 150
# Include an extra buffer for potential autocorrelator messages.
- 750
)
# Make space for the truncation message plus two newline characters.
truncate_length -= len(truncation_message) + 2
# TODO(olivernewman): In Python 3 the length of a unicode character is
# always one regardless of how many bytes it contains, so we'll need to
# adjust max_line_length by `len(dots.encode())` to make it correct.
dots = "…"
truncated_line = False
failure_lines = []
for line in raw_text.rstrip().split("\n"):
if len(line) > max_line_length:
line = unicode_truncate(line, max_line_length - len(dots)) + dots
truncated_line = True
failure_lines.append(line)
final_summary = "\n".join(failure_lines)
if len(final_summary) > truncate_length:
final_summary = (
unicode_truncate(final_summary, truncate_length - len(dots)) + dots
)
elif not truncated_line:
# No need to include a truncation warning if we didn't truncate
# any lines or the entire failure summary.
truncation_message = ""
# Render the error details as code in the Milo build summary so
# that whitespace is respected; otherwise it would be ignored
# by Milo's markdown renderer.
if escape_markdown:
final_summary = "```\n%s\n```" % final_summary
return final_summary + (
"\n\n%s" % truncation_message if truncation_message else ""
)
def unicode_truncate(string, max_bytes):
"""Truncates a string while avoiding splitting unicode characters.
The resulting string will have have len() <= max_bytes and will contain
valid UTF-8.
"""
chars = []
byte_count = 0
for unicode_char in string.decode("utf-8"):
encoded = unicode_char.encode("utf-8")
if byte_count + len(encoded) > max_bytes:
break
chars.append(encoded)
byte_count += len(encoded)
return "".join(chars)