blob: d9eb4bbecb0f3a66f32c15ca1f117c4d52d0460c [file] [log] [blame]
// Copyright 2019 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"unicode/utf8"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/proto"
"github.com/golang/protobuf/ptypes"
"github.com/golang/protobuf/ptypes/timestamp"
"github.com/mgutz/ansi"
"google.golang.org/grpc/status"
"go.chromium.org/luci/common/data/text/color"
"go.chromium.org/luci/common/data/text/indented"
pb "go.chromium.org/luci/buildbucket/proto"
)
var (
ansiWhiteBold = ansi.ColorCode("white+b")
ansiWhiteUnderline = ansi.ColorCode("white+u")
ansiStatus = map[pb.Status]string{
pb.Status_SCHEDULED: ansi.LightWhite,
pb.Status_STARTED: ansi.LightYellow,
pb.Status_SUCCESS: ansi.LightGreen,
pb.Status_FAILURE: ansi.LightRed,
pb.Status_INFRA_FAILURE: ansi.LightMagenta,
}
)
// printer can print a buildbucket build to a io.Writer in a human-friendly
// format.
//
// First time writing fails, the error is saved to Err.
// Further attempts to write are noop.
type printer struct {
// Err is not nil if printing to the writer failed.
// If it is not nil, methods are noop.
Err error
nowFn func() time.Time
// used to indent text. printer.f always writes to this writer.
indent indented.Writer
}
func newPrinter(w io.Writer, disableColor bool, nowFn func() time.Time) *printer {
// Stack writers together.
// w always points to the stack top.
p := &printer{nowFn: nowFn}
if disableColor {
w = &color.StripWriter{Writer: w}
}
p.indent.Writer = w
p.indent.UseSpaces = true
return p
}
func newStdioPrinters(disableColor bool) (stdout, stderr *printer) {
disableColor = disableColor || shouldDisableColors()
stdout = newPrinter(os.Stdout, disableColor, time.Now)
stderr = newPrinter(os.Stderr, disableColor, time.Now)
return
}
// f prints a formatted message.
func (p *printer) f(format string, args ...interface{}) {
if p.Err != nil {
return
}
if _, err := fmt.Fprintf(&p.indent, format, args...); err != nil && err != io.ErrShortWrite {
p.Err = err
}
}
// fw is like f, but appends whitespace such that the printed string takes at
// least minWidth.
// Appends at least one space.
func (p *printer) fw(minWidth int, format string, args ...interface{}) {
s := fmt.Sprintf(format, args...)
pad := minWidth - utf8.RuneCountInString(s)
if pad < 1 {
pad = 1
}
p.f("%s%s", s, strings.Repeat(" ", pad))
}
// JSONPB prints pb in either compact or indented JSON format according to the
// provided compact boolean
func (p *printer) JSONPB(pb proto.Message, compact bool) {
m := &jsonpb.Marshaler{}
buf := &bytes.Buffer{}
if err := m.Marshal(buf, pb); err != nil {
panic(fmt.Errorf("failed to marshal a message: %s", err))
}
out := &bytes.Buffer{}
var err error
if compact {
err = json.Compact(out, buf.Bytes())
} else {
// Note: json.Marshal indents JSON more nicely than jsonpb.Marshaler.Indent.
err = json.Indent(out, buf.Bytes(), "", " ")
}
if err != nil {
panic(err)
}
p.f("%s\n", out.Bytes())
}
// Build prints b. Panic when id, status or any fields under builder is missing
func (p *printer) Build(b *pb.Build) {
// Id, Status and Builder are explicitly added to field mask so they should
// always be present. Doing defensive check here to avoid any unexpected
// conditions to corrupt the printed build.
if b.Id == 0 {
panic(fmt.Errorf("expect non zero id present in the build"))
}
if b.Status == pb.Status_STATUS_UNSPECIFIED {
panic(fmt.Errorf("expect non zero value status present in the build"))
}
if builder := b.Builder; builder == nil ||
builder.Project == "" || builder.Bucket == "" || builder.Builder == "" {
panic(fmt.Errorf("expect builder present in the build and all fields under builder should be non zero value. Got: %v", builder))
}
// Print the build URL bold, underline and a color matching the status.
p.f("%s%s%shttp://ci.chromium.org/b/%d", ansiWhiteBold, ansiWhiteUnderline, ansiStatus[b.Status], b.Id)
// Undo underline.
p.f("%s%s%s ", ansi.Reset, ansiWhiteBold, ansiStatus[b.Status])
p.fw(10, "%s", b.Status)
p.f("'%s/%s/%s", b.Builder.Project, b.Builder.Bucket, b.Builder.Builder)
if b.Number != 0 {
p.f("/%d", b.Number)
}
p.f("'%s\n", ansi.Reset)
// Summary.
if b.SummaryMarkdown != "" {
p.attr("Summary")
p.summary(b.SummaryMarkdown)
}
var systemTags []string
if b.Input.GetExperimental() {
systemTags = append(systemTags, "Experimental")
}
if b.Canary {
systemTags = append(systemTags, "Canary")
}
if len(systemTags) > 0 {
for i, t := range systemTags {
if i > 0 {
p.f(" ")
}
p.keyword(t)
}
p.f("\n")
}
// Timing.
if b.CreateTime != nil {
p.buildTime(b)
p.f("\n")
}
if b.CreatedBy != "" {
p.attr("By")
p.f("%s\n", b.CreatedBy)
}
// Commit, CLs and tags.
if c := b.Input.GetGitilesCommit(); c != nil {
p.attr("Commit")
p.commit(c)
p.f("\n")
}
for _, cl := range b.Input.GetGerritChanges() {
p.attr("CL")
p.change(cl)
p.f("\n")
}
for _, t := range b.Tags {
p.attr("Tag")
p.f("%s:%s\n", t.Key, t.Value)
}
// Properties
if props := b.Input.GetProperties(); props != nil {
p.attr("Input properties")
p.JSONPB(props, false)
}
if props := b.Output.GetProperties(); props != nil {
p.attr("Output properties")
p.JSONPB(props, false)
}
// Steps
p.steps(b.Steps)
}
// commit prints c.
func (p *printer) commit(c *pb.GitilesCommit) {
if c.Id == "" {
p.linkf("https://%s/%s/+/%s", c.Host, c.Project, c.Ref)
return
}
switch c.Host {
// This shamelessly hardcodes https://cr-rev.appspot.com/_ah/api/crrev/v1/projects response
// TODO(nodir): make an RPC and cache on the file system.
case
"aomedia.googlesource.com",
"boringssl.googlesource.com",
"chromium.googlesource.com",
"gerrit.googlesource.com",
"webrtc.googlesource.com":
p.linkf("https://crrev.com/" + c.Id)
default:
p.linkf("https://%s/%s/+/%s", c.Host, c.Project, c.Id)
}
if c.Ref != "" {
p.f(" on %s", c.Ref)
}
}
// change prints cl.
func (p *printer) change(cl *pb.GerritChange) {
switch {
case cl.Host == "chromium-review.googlesource.com":
p.linkf("https://crrev.com/c/%d/%d", cl.Change, cl.Patchset)
case cl.Host == "chrome-internal-review.googlesource.com":
p.linkf("https://crrev.com/i/%d/%d", cl.Change, cl.Patchset)
default:
p.linkf("https://%s/c/%s/+/%d/%d", cl.Host, cl.Project, cl.Change, cl.Patchset)
}
}
// steps print steps.
func (p *printer) steps(steps []*pb.Step) {
maxNameWidth := 0
for _, s := range steps {
if w := utf8.RuneCountInString(s.Name); w > maxNameWidth {
maxNameWidth = w
}
}
for _, s := range steps {
p.f("%sStep ", ansiStatus[s.Status])
p.fw(maxNameWidth+5, "%q", s.Name)
p.fw(10, "%s", s.Status)
// Print duration.
durString := ""
if start, err := ptypes.Timestamp(s.StartTime); err == nil {
var stepDur time.Duration
if end, err := ptypes.Timestamp(s.EndTime); err == nil {
stepDur = end.Sub(start)
} else {
now := p.nowFn()
stepDur = now.Sub(start.In(now.Location()))
}
durString = truncateDuration(stepDur).String()
}
p.fw(10, "%s", durString)
// Print log names.
// Do not print log URLs because they are very long and
// bb has `log` subcommand.
if len(s.Logs) > 0 {
p.f("Logs: ")
for i, l := range s.Logs {
if i > 0 {
p.f(", ")
}
p.f("%q", l.Name)
}
}
p.f("%s\n", ansi.Reset)
p.indent.Level += 2
if s.SummaryMarkdown != "" {
// TODO(nodir): transform lists of links to look like logs
p.summary(s.SummaryMarkdown)
}
p.indent.Level -= 2
}
}
func (p *printer) buildTime(b *pb.Build) {
now := p.nowFn()
created := readTimestamp(b.CreateTime).In(now.Location())
started := readTimestamp(b.StartTime).In(now.Location())
ended := readTimestamp(b.EndTime).In(now.Location())
if created.IsZero() {
return
}
p.keyword("Created")
p.f(" ")
p.dateTime(created)
if started.IsZero() && ended.IsZero() {
if !p.isJustNow(created) {
// did not start or end yet
p.f(", ")
p.keyword("waiting")
p.f(" for %s, ", truncateDuration(now.Sub(created)))
}
return
}
if !started.IsZero() {
// did not start yet
p.f(", ")
p.keyword("waited")
p.f(" %s, ", truncateDuration(started.Sub(created)))
p.keyword("started")
p.f(" ")
p.time(started)
}
if ended.IsZero() {
// did not end yet
if !started.IsZero() {
// running now
p.f(", ")
p.keyword("running")
p.f(" for %s", truncateDuration(now.Sub(started)))
}
} else {
// ended
if !started.IsZero() {
// started in the past
p.f(", ")
p.keyword("ran")
p.f(" for %s", truncateDuration(ended.Sub(started)))
}
p.f(", ")
p.keyword("ended")
p.f(" ")
p.time(ended)
}
}
func (p *printer) summary(summaryMarkdown string) {
// TODO(nodir): color markdown.
p.f("%s\n", strings.TrimSpace(summaryMarkdown))
}
func (p *printer) dateTime(t time.Time) {
if p.isJustNow(t) {
p.f("just now")
} else {
p.date(t)
p.f(" ")
p.time(t)
}
}
func (p *printer) date(t time.Time) {
if p.isToday(t) {
p.f("today")
} else {
p.f("on %s", t.Format("2006-01-02"))
}
}
func (p *printer) time(t time.Time) {
if p.isJustNow(t) {
p.f("just now")
} else {
p.f("at %s", t.Format("15:04:05"))
}
}
func (p *printer) isJustNow(t time.Time) bool {
now := p.nowFn()
elapsed := now.Sub(t.In(now.Location()))
return elapsed > 0 && elapsed < 10*time.Second
}
func (p *printer) attr(s string) {
p.keyword(s)
p.f(": ")
}
func (p *printer) keyword(s string) {
p.f("%s%s%s", ansiWhiteBold, s, ansi.Reset)
}
func (p *printer) linkf(format string, args ...interface{}) {
p.f("%s", ansiWhiteUnderline)
p.f(format, args...)
p.f("%s", ansi.Reset)
}
// Error prints the err. If err is a gRPC error, then prints only the message
// without the code.
func (p *printer) Error(err error) {
st, _ := status.FromError(err)
p.f("%s", st.Message())
}
// readTimestamp converts ts to time.Time.
// Returns zero if ts is invalid.
func readTimestamp(ts *timestamp.Timestamp) time.Time {
t, err := ptypes.Timestamp(ts)
if err != nil {
return time.Time{}
}
return t
}
func (p *printer) isToday(t time.Time) bool {
now := p.nowFn()
nYear, nMonth, nDay := now.Date()
tYear, tMonth, tDay := t.In(now.Location()).Date()
return tYear == nYear && tMonth == nMonth && tDay == nDay
}
var durTransactions = []struct{ threshold, round time.Duration }{
{time.Hour, time.Minute},
{time.Minute, time.Second},
{time.Second, time.Second / 10},
{time.Millisecond, time.Millisecond},
}
// truncateDuration truncates d to make it more human-readable.
func truncateDuration(d time.Duration) time.Duration {
for _, t := range durTransactions {
if d > t.threshold {
return d.Round(t.round)
}
}
return d
}