go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/cli/print.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cli
    16  
    17  import (
    18  	"bytes"
    19  	"encoding/json"
    20  	"fmt"
    21  	"io"
    22  	"os"
    23  	"strings"
    24  	"time"
    25  	"unicode/utf8"
    26  
    27  	"github.com/golang/protobuf/jsonpb"
    28  	"github.com/golang/protobuf/proto"
    29  	"github.com/mgutz/ansi"
    30  	"google.golang.org/grpc/status"
    31  	"google.golang.org/protobuf/types/known/timestamppb"
    32  
    33  	"go.chromium.org/luci/common/data/stringset"
    34  	"go.chromium.org/luci/common/data/text/color"
    35  	"go.chromium.org/luci/common/data/text/indented"
    36  
    37  	bb "go.chromium.org/luci/buildbucket"
    38  	pb "go.chromium.org/luci/buildbucket/proto"
    39  	"go.chromium.org/luci/buildbucket/protoutil"
    40  )
    41  
    42  var (
    43  	ansiWhiteBold      = ansi.ColorCode("white+b")
    44  	ansiWhiteUnderline = ansi.ColorCode("white+u")
    45  	ansiStatus         = map[pb.Status]string{
    46  		pb.Status_SCHEDULED:     ansi.LightWhite,
    47  		pb.Status_STARTED:       ansi.LightYellow,
    48  		pb.Status_SUCCESS:       ansi.LightGreen,
    49  		pb.Status_FAILURE:       ansi.LightRed,
    50  		pb.Status_INFRA_FAILURE: ansi.LightMagenta,
    51  	}
    52  )
    53  
    54  // printer can print a buildbucket build to a io.Writer in a human-friendly
    55  // format.
    56  //
    57  // First time writing fails, the error is saved to Err.
    58  // Further attempts to write are noop.
    59  type printer struct {
    60  	// Err is not nil if printing to the writer failed.
    61  	// If it is not nil, methods are noop.
    62  	Err error
    63  
    64  	nowFn func() time.Time
    65  
    66  	// used to indent text. printer.f always writes to this writer.
    67  	indent indented.Writer
    68  }
    69  
    70  func newPrinter(w io.Writer, disableColor bool, nowFn func() time.Time) *printer {
    71  	// Stack writers together.
    72  	// w always points to the stack top.
    73  
    74  	p := &printer{nowFn: nowFn}
    75  
    76  	if disableColor {
    77  		w = &color.StripWriter{Writer: w}
    78  	}
    79  
    80  	p.indent.Writer = w
    81  	p.indent.UseSpaces = true
    82  	return p
    83  }
    84  
    85  func newStdioPrinters(disableColor bool) (stdout, stderr *printer) {
    86  	disableColor = disableColor || shouldDisableColors()
    87  	stdout = newPrinter(os.Stdout, disableColor, time.Now)
    88  	stderr = newPrinter(os.Stderr, disableColor, time.Now)
    89  	return
    90  }
    91  
    92  // f prints a formatted message.
    93  func (p *printer) f(format string, args ...any) {
    94  	if p.Err != nil {
    95  		return
    96  	}
    97  	if _, err := fmt.Fprintf(&p.indent, format, args...); err != nil && err != io.ErrShortWrite {
    98  		p.Err = err
    99  	}
   100  }
   101  
   102  // fw is like f, but appends whitespace such that the printed string takes at
   103  // least minWidth.
   104  // Appends at least one space.
   105  func (p *printer) fw(minWidth int, format string, args ...any) {
   106  	s := fmt.Sprintf(format, args...)
   107  	pad := minWidth - utf8.RuneCountInString(s)
   108  	if pad < 1 {
   109  		pad = 1
   110  	}
   111  	p.f("%s%s", s, strings.Repeat(" ", pad))
   112  }
   113  
   114  // JSONPB prints pb in either compact or indented JSON format according to the
   115  // provided compact boolean
   116  func (p *printer) JSONPB(pb proto.Message, compact bool) {
   117  	m := &jsonpb.Marshaler{}
   118  	buf := &bytes.Buffer{}
   119  	if err := m.Marshal(buf, pb); err != nil {
   120  		panic(fmt.Errorf("failed to marshal a message: %s", err))
   121  	}
   122  
   123  	out := &bytes.Buffer{}
   124  	var err error
   125  	if compact {
   126  		err = json.Compact(out, buf.Bytes())
   127  	} else {
   128  		// Note: json.Marshal indents JSON more nicely than jsonpb.Marshaler.Indent.
   129  		err = json.Indent(out, buf.Bytes(), "", "  ")
   130  	}
   131  	if err != nil {
   132  		panic(err)
   133  	}
   134  
   135  	p.f("%s\n", out.Bytes())
   136  }
   137  
   138  // Build prints b. Panic when id, status or any fields under builder is missing
   139  func (p *printer) Build(b *pb.Build) {
   140  	// Id, Status and Builder are explicitly added to field mask so they should
   141  	// always be present. Doing defensive check here to avoid any unexpected
   142  	// conditions to corrupt the printed build.
   143  	if b.Id == 0 {
   144  		panic(fmt.Errorf("expect non zero id present in the build"))
   145  	}
   146  	if b.Status == pb.Status_STATUS_UNSPECIFIED {
   147  		panic(fmt.Errorf("expect non zero value status present in the build"))
   148  	}
   149  	if builder := b.Builder; builder == nil ||
   150  		builder.Project == "" || builder.Bucket == "" || builder.Builder == "" {
   151  		panic(fmt.Errorf("expect builder present in the build and all fields under builder should be non zero value. Got: %v", builder))
   152  	}
   153  
   154  	// Print the build URL bold, underline and a color matching the status.
   155  	p.f("%s%s%shttp://ci.chromium.org/b/%d", ansiWhiteBold, ansiWhiteUnderline, ansiStatus[b.Status], b.Id)
   156  	// Undo underline.
   157  	p.f("%s%s%s ", ansi.Reset, ansiWhiteBold, ansiStatus[b.Status])
   158  	p.fw(10, "%s", b.Status)
   159  	p.f("'%s/%s/%s", b.Builder.Project, b.Builder.Bucket, b.Builder.Builder)
   160  	if b.Number != 0 {
   161  		p.f("/%d", b.Number)
   162  	}
   163  	p.f("'%s\n", ansi.Reset)
   164  
   165  	// Summary.
   166  	if b.SummaryMarkdown != "" {
   167  		p.attr("Summary")
   168  		p.summary(b.SummaryMarkdown)
   169  	}
   170  
   171  	experiments := stringset.NewFromSlice(b.Input.GetExperiments()...)
   172  
   173  	var systemTags []string
   174  	if experiments.Has(bb.ExperimentNonProduction) {
   175  		systemTags = append(systemTags, "Experimental")
   176  	}
   177  	if experiments.Has(bb.ExperimentBBCanarySoftware) {
   178  		systemTags = append(systemTags, "Canary")
   179  	}
   180  	if len(systemTags) > 0 {
   181  		for i, t := range systemTags {
   182  			if i > 0 {
   183  				p.f(" ")
   184  			}
   185  			p.keyword(t)
   186  		}
   187  		p.f("\n")
   188  	}
   189  
   190  	// Timing.
   191  	if b.CreateTime != nil {
   192  		p.buildTime(b)
   193  		p.f("\n")
   194  	}
   195  
   196  	if b.CreatedBy != "" {
   197  		p.attr("By")
   198  		p.f("%s\n", b.CreatedBy)
   199  	}
   200  
   201  	// Commit, CLs and tags.
   202  	if c := b.Input.GetGitilesCommit(); c != nil {
   203  		p.attr("Commit")
   204  		p.commit(c)
   205  		p.f("\n")
   206  	}
   207  	for _, cl := range b.Input.GetGerritChanges() {
   208  		p.attr("CL")
   209  		p.change(cl)
   210  		p.f("\n")
   211  	}
   212  	for _, t := range b.Tags {
   213  		p.attr("Tag")
   214  		p.f("%s:%s\n", t.Key, t.Value)
   215  	}
   216  
   217  	// Properties
   218  	if props := b.Input.GetProperties(); props != nil {
   219  		p.attr("Input properties")
   220  		p.JSONPB(props, false)
   221  	}
   222  
   223  	if props := b.Output.GetProperties(); props != nil {
   224  		p.attr("Output properties")
   225  		p.JSONPB(props, false)
   226  	}
   227  
   228  	// Experiments
   229  	if exps := b.Input.GetExperiments(); len(exps) > 0 {
   230  		p.attr("Experiments")
   231  		p.f("\n")
   232  		p.indent.Level += 2
   233  		for _, exp := range exps {
   234  			p.f("%s\n", exp)
   235  		}
   236  		p.indent.Level -= 2
   237  	}
   238  
   239  	// Steps
   240  	p.steps(b.Steps)
   241  }
   242  
   243  // commit prints c.
   244  func (p *printer) commit(c *pb.GitilesCommit) {
   245  	if c.Id == "" {
   246  		p.linkf("https://%s/%s/+/%s", c.Host, c.Project, c.Ref)
   247  		return
   248  	}
   249  
   250  	switch c.Host {
   251  	// This shamelessly hardcodes https://cr-rev.appspot.com/_ah/api/crrev/v1/projects response
   252  	// TODO(nodir): make an RPC and cache on the file system.
   253  	case
   254  		"aomedia.googlesource.com",
   255  		"boringssl.googlesource.com",
   256  		"chromium.googlesource.com",
   257  		"gerrit.googlesource.com",
   258  		"webrtc.googlesource.com":
   259  		p.linkf("https://crrev.com/" + c.Id)
   260  	default:
   261  		p.linkf("https://%s/%s/+/%s", c.Host, c.Project, c.Id)
   262  	}
   263  	if c.Ref != "" {
   264  		p.f(" on %s", c.Ref)
   265  	}
   266  }
   267  
   268  // change prints cl.
   269  func (p *printer) change(cl *pb.GerritChange) {
   270  	switch {
   271  	case cl.Host == "chromium-review.googlesource.com":
   272  		p.linkf("https://crrev.com/c/%d/%d", cl.Change, cl.Patchset)
   273  	case cl.Host == "chrome-internal-review.googlesource.com":
   274  		p.linkf("https://crrev.com/i/%d/%d", cl.Change, cl.Patchset)
   275  	default:
   276  		p.linkf("https://%s/c/%s/+/%d/%d", cl.Host, cl.Project, cl.Change, cl.Patchset)
   277  	}
   278  }
   279  
   280  // steps print steps.
   281  func (p *printer) steps(steps []*pb.Step) {
   282  	maxNameWidth := 0
   283  	for _, s := range steps {
   284  		if w := utf8.RuneCountInString(s.Name); w > maxNameWidth {
   285  			maxNameWidth = w
   286  		}
   287  	}
   288  
   289  	for _, s := range steps {
   290  		p.f("%sStep ", ansiStatus[s.Status])
   291  		p.fw(maxNameWidth+5, "%q", s.Name)
   292  		p.fw(10, "%s", s.Status)
   293  
   294  		// Print duration.
   295  		durString := ""
   296  		if start := s.StartTime.AsTime(); s.StartTime != nil {
   297  			var stepDur time.Duration
   298  			if end := s.EndTime.AsTime(); s.EndTime != nil {
   299  				stepDur = end.Sub(start)
   300  			} else {
   301  				now := p.nowFn()
   302  				stepDur = now.Sub(start.In(now.Location()))
   303  			}
   304  			durString = truncateDuration(stepDur).String()
   305  		}
   306  		p.fw(10, "%s", durString)
   307  
   308  		// Print log names.
   309  		// Do not print log URLs because they are very long and
   310  		// bb has `log` subcommand.
   311  		if len(s.Logs) > 0 {
   312  			p.f("Logs: ")
   313  			for i, l := range s.Logs {
   314  				if i > 0 {
   315  					p.f(", ")
   316  				}
   317  				p.f("%q", l.Name)
   318  			}
   319  		}
   320  
   321  		p.f("%s\n", ansi.Reset)
   322  
   323  		p.indent.Level += 2
   324  		if s.SummaryMarkdown != "" {
   325  			// TODO(nodir): transform lists of links to look like logs
   326  			p.summary(s.SummaryMarkdown)
   327  		}
   328  		p.indent.Level -= 2
   329  	}
   330  }
   331  
   332  func (p *printer) buildTime(b *pb.Build) {
   333  	now := p.nowFn()
   334  	created := readTimestamp(b.CreateTime).In(now.Location())
   335  	started := readTimestamp(b.StartTime).In(now.Location())
   336  	ended := readTimestamp(b.EndTime).In(now.Location())
   337  
   338  	if created.IsZero() {
   339  		return
   340  	}
   341  	p.keyword("Created")
   342  	p.f(" ")
   343  	p.dateTime(created)
   344  
   345  	// Since the `fields` part of the request is partially controlled by the user,
   346  	// the nil timestamp fields in fetched build aren't trustworthy.
   347  	// So, check by `status` first,which is always set, and only then try to print
   348  	// additional information if semantically fitting.
   349  
   350  	if b.Status == pb.Status_SCHEDULED {
   351  		if !p.isJustNow(created) {
   352  			p.f(", ")
   353  			p.keyword("waiting")
   354  			p.f(" for %s, ", truncateDuration(now.Sub(created)))
   355  		}
   356  		return
   357  	}
   358  
   359  	if !started.IsZero() {
   360  		p.f(", ")
   361  		p.keyword("waited")
   362  		p.f(" %s, ", truncateDuration(started.Sub(created)))
   363  		p.keyword("started")
   364  		p.f(" ")
   365  		p.time(started)
   366  	}
   367  
   368  	if !protoutil.IsEnded(b.Status) {
   369  		if !started.IsZero() {
   370  			// running now
   371  			p.f(", ")
   372  			p.keyword("running")
   373  			p.f(" for %s", truncateDuration(now.Sub(started)))
   374  		}
   375  	} else {
   376  		// ended
   377  		if !started.IsZero() {
   378  			// started in the past
   379  			p.f(", ")
   380  			p.keyword("ran")
   381  			p.f(" for %s", truncateDuration(ended.Sub(started)))
   382  		}
   383  		if !ended.IsZero() {
   384  			p.f(", ")
   385  			p.keyword("ended")
   386  			p.f(" ")
   387  			p.time(ended)
   388  		}
   389  	}
   390  }
   391  
   392  func (p *printer) summary(summaryMarkdown string) {
   393  	// TODO(nodir): color markdown.
   394  	p.f("%s\n", strings.TrimSpace(summaryMarkdown))
   395  }
   396  
   397  func (p *printer) dateTime(t time.Time) {
   398  	if p.isJustNow(t) {
   399  		p.f("just now")
   400  	} else {
   401  		p.date(t)
   402  		p.f(" ")
   403  		p.time(t)
   404  	}
   405  }
   406  
   407  func (p *printer) date(t time.Time) {
   408  	if p.isToday(t) {
   409  		p.f("today")
   410  	} else {
   411  		p.f("on %s", t.Format("2006-01-02"))
   412  	}
   413  }
   414  
   415  func (p *printer) time(t time.Time) {
   416  	if p.isJustNow(t) {
   417  		p.f("just now")
   418  	} else {
   419  		p.f("at %s", t.Format("15:04:05"))
   420  	}
   421  }
   422  
   423  func (p *printer) isJustNow(t time.Time) bool {
   424  	now := p.nowFn()
   425  	elapsed := now.Sub(t.In(now.Location()))
   426  	return elapsed > 0 && elapsed < 10*time.Second
   427  }
   428  
   429  func (p *printer) attr(s string) {
   430  	p.keyword(s)
   431  	p.f(": ")
   432  }
   433  
   434  func (p *printer) keyword(s string) {
   435  	p.f("%s%s%s", ansiWhiteBold, s, ansi.Reset)
   436  }
   437  
   438  func (p *printer) linkf(format string, args ...any) {
   439  	p.f("%s", ansiWhiteUnderline)
   440  	p.f(format, args...)
   441  	p.f("%s", ansi.Reset)
   442  }
   443  
   444  // Error prints the err. If err is a gRPC error, then prints only the message
   445  // without the code.
   446  func (p *printer) Error(err error) {
   447  	st, _ := status.FromError(err)
   448  	p.f("%s", st.Message())
   449  }
   450  
   451  // readTimestamp converts ts to time.Time.
   452  // Returns zero if ts is invalid.
   453  func readTimestamp(ts *timestamppb.Timestamp) time.Time {
   454  	if err := ts.CheckValid(); err != nil {
   455  		return time.Time{}
   456  	}
   457  	t := ts.AsTime()
   458  	return t
   459  }
   460  
   461  func (p *printer) isToday(t time.Time) bool {
   462  	now := p.nowFn()
   463  	nYear, nMonth, nDay := now.Date()
   464  	tYear, tMonth, tDay := t.In(now.Location()).Date()
   465  	return tYear == nYear && tMonth == nMonth && tDay == nDay
   466  }
   467  
   468  var durTransactions = []struct{ threshold, round time.Duration }{
   469  	{time.Hour, time.Minute},
   470  	{time.Minute, time.Second},
   471  	{time.Second, time.Second / 10},
   472  	{time.Millisecond, time.Millisecond},
   473  }
   474  
   475  // truncateDuration truncates d to make it more human-readable.
   476  func truncateDuration(d time.Duration) time.Duration {
   477  	for _, t := range durTransactions {
   478  		if d > t.threshold {
   479  			return d.Round(t.round)
   480  		}
   481  	}
   482  	return d
   483  }