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 }