blob: bb7f7f7748d774ed50813af3894b438dcde8fc85 [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"strconv"
"github.com/maruel/subcommands"
"github.com/texttheater/golang-levenshtein/levenshtein"
"go.chromium.org/luci/auth"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/proto/git"
"go.chromium.org/luci/grpc/prpc"
"google.golang.org/genproto/protobuf/field_mask"
"go.fuchsia.dev/infra/buildbucket"
"go.fuchsia.dev/infra/cmd/autocorrelator/compare"
"go.fuchsia.dev/infra/cmd/autocorrelator/findings"
"go.fuchsia.dev/infra/gitiles"
)
func cmdCheckCI(authOpts auth.Options) *subcommands.Command {
return &subcommands.Command{
UsageLine: "check-ci -base-commit <sha1> -builder <project/bucket/builder> -build-status <build-status-code> -search-range <search-range> -summary-markdown-path <path/to/summary/markdown> -json-output <json-output>",
ShortDesc: "Compare text similarity between a summary markdown and recent CI builds' summary markdowns.",
LongDesc: "Compare text similarity between the provided -summary-markdown and the summary markdowns of -search-range recent builds of a CI -builder.",
CommandRun: func() subcommands.CommandRun {
c := &checkCIRun{}
c.Init(authOpts)
return c
},
}
}
type checkCIRun struct {
commonFlags
baseCommit string
// TODO(atyfto): Fix the buildbucket library to return a BuilderID instead
// of a flag.
builder buildbucket.BuilderIDFlag
buildStatus int
searchRange int
summaryMarkdownPath string
jsonOutput string
}
func (c *checkCIRun) Init(defaultAuthOpts auth.Options) {
c.commonFlags.Init(defaultAuthOpts)
c.Flags.StringVar(&c.baseCommit, "base-commit", "", "Base commit as sha1.")
c.Flags.Var(&c.builder, "builder", "Fully-qualified Buildbucket CI builder name to inspect.")
c.Flags.IntVar(&c.buildStatus, "build-status", 20, "Build status to filter on. Defaults to FAILURE.")
c.Flags.IntVar(&c.searchRange, "search-range", 10, "Inspect up to this many recent builds.")
c.Flags.StringVar(&c.summaryMarkdownPath, "summary-markdown-path", "", "Path to summary markdown input file.")
c.Flags.StringVar(&c.jsonOutput, "json-output", "-", "Path to output finding to.")
}
func (c *checkCIRun) Parse() error {
if err := c.commonFlags.Parse(); err != nil {
return err
}
if c.baseCommit == "" {
return errors.New("-base-commit is required")
}
if &c.builder == nil {
return errors.New("-builder is required")
}
if c.summaryMarkdownPath == "" {
return errors.New("-summary-markdown-path is required")
}
if c.jsonOutput == "" {
return errors.New("-json-output is required")
}
return nil
}
// checkCI is the main algorithm which backs this subcommand.
//
// Given a git log and a list of builds, return a summary similarity finding.
// This function assumes the list of builds is "CI-like": the builds should
// be in a git history order.
//
// The `comparator` argument is a text-based comparator that returns a
// similarity score between the input summary markdown and a build's summary
// markdown.
func checkCI(log []*git.Commit, builds []*buildbucketpb.Build, status buildbucketpb.Status, summaryMarkdown string, comparator compare.TextComparator) *findings.SummarySimilarity {
// Create a dual-purpose commit map for efficient existence checks and
// commit distance computation.
commitMap := make(map[string]int)
for idx, commit := range log {
commitMap[commit.Id] = idx
}
// Search the list of builds for a matching status.
for _, build := range builds {
// Ignore builds which aren't in the base build's log, i.e. equal to
// the base commit or older.
if _, ok := commitMap[build.Input.GitilesCommit.Id]; !ok {
continue
}
// If we encounter a green build, short-circuit immediately and return
// a finding which indicates as such.
if build.Status == buildbucketpb.Status_SUCCESS {
return &findings.SummarySimilarity{
BuildId: strconv.FormatInt(build.Id, 10),
// Commit distance is the commit's position in the log.
CommitDist: commitMap[build.Input.GitilesCommit.Id],
IsGreen: true,
}
}
// If we encounter a build with a matching status, compute the summary
// markdown similarity.
if build.Status == status {
return &findings.SummarySimilarity{
BuildId: strconv.FormatInt(build.Id, 10),
// Commit distance is the commit's position in the log.
CommitDist: commitMap[build.Input.GitilesCommit.Id],
Score: comparator.Compare(build.SummaryMarkdown, summaryMarkdown),
}
}
}
// If we've exhausted the search, we do not have a finding.
return nil
}
func (c *checkCIRun) main() error {
ctx := context.Background()
authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.parsedAuthOpts).Client()
if err != nil {
return fmt.Errorf("failed to initialize auth client: %v", err)
}
// Fetch the last -search-range completed builds of -builder.
buildsClient := buildbucketpb.NewBuildsPRPCClient(&prpc.Client{
C: authClient,
Host: c.bbHost,
})
// TODO(atyfto): Fix the `buildbucket` library so we can get rid of this
// type assertion.
builder, ok := c.builder.Get().(*buildbucketpb.BuilderID)
if !ok {
return errors.New("builder input is invalid")
}
resp, err := buildsClient.SearchBuilds(ctx, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
Builder: builder,
Status: buildbucketpb.Status_ENDED_MASK,
},
Fields: &field_mask.FieldMask{Paths: []string{
"builds.*.id",
"builds.*.input.gitiles_commit",
"builds.*.status",
"builds.*.summary_markdown",
}},
PageSize: int32(c.searchRange),
})
if err != nil {
return fmt.Errorf("failed to query builds for %s: %v", c.builder.String(), err)
}
builds := resp.Builds
if len(builds) == 0 {
return fmt.Errorf("no builds found for %s", c.builder.String())
}
// Grab the git log. Since we cannot know upfront how many commits will
// capture the entire range of builds, fetch a large multiplier of the
// search range.
gitilesHost := builds[0].Input.GitilesCommit.Host
gitilesProject := builds[0].Input.GitilesCommit.Project
gitilesClient, err := gitiles.NewClient(ctx, gitilesHost, gitilesProject, authClient)
if err != nil {
return fmt.Errorf("failed to initialize gitiles client for %s/%s: %v", gitilesHost, gitilesProject, err)
}
log, err := gitilesClient.Log(ctx, c.baseCommit, int32(c.searchRange*20))
if err != nil {
return fmt.Errorf("failed to fetch git log for %s: %v", c.baseCommit, err)
}
// Run the main algorithm.
summaryMarkdown, err := ioutil.ReadFile(c.summaryMarkdownPath)
if err != nil {
return fmt.Errorf("could not read summary markdown input: %v", err)
}
ss := checkCI(log, builds, buildbucketpb.Status(c.buildStatus), string(summaryMarkdown), compare.LevenshteinComparator{Opts: levenshtein.DefaultOptions})
// Emit summary similarity finding to -json-output.
out := os.Stdout
if c.jsonOutput != "-" {
out, err := os.Create(c.jsonOutput)
if err != nil {
return err
}
defer out.Close()
}
if err := json.NewEncoder(out).Encode(ss); err != nil {
return fmt.Errorf("failed to encode: %v", err)
}
return nil
}
func (c *checkCIRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
if err := c.Parse(); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
if err := c.main(); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
return 0
}