go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/autocorrelator/check_ci.go (about)

     1  // Copyright 2021 The Fuchsia Authors.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"os"
    13  	"strconv"
    14  
    15  	"github.com/maruel/subcommands"
    16  	"github.com/texttheater/golang-levenshtein/levenshtein"
    17  	"go.chromium.org/luci/auth"
    18  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    19  	"go.chromium.org/luci/common/proto/git"
    20  	"go.chromium.org/luci/grpc/prpc"
    21  	"google.golang.org/genproto/protobuf/field_mask"
    22  
    23  	"go.fuchsia.dev/infra/buildbucket"
    24  	"go.fuchsia.dev/infra/cmd/autocorrelator/compare"
    25  	"go.fuchsia.dev/infra/cmd/autocorrelator/findings"
    26  	"go.fuchsia.dev/infra/gitiles"
    27  )
    28  
    29  func cmdCheckCI(authOpts auth.Options) *subcommands.Command {
    30  	return &subcommands.Command{
    31  		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>",
    32  		ShortDesc: "Compare text similarity between a summary markdown and recent CI builds' summary markdowns.",
    33  		LongDesc:  "Compare text similarity between the provided -summary-markdown and the summary markdowns of -search-range recent builds of a CI -builder.",
    34  		CommandRun: func() subcommands.CommandRun {
    35  			c := &checkCIRun{}
    36  			c.Init(authOpts)
    37  			return c
    38  		},
    39  	}
    40  }
    41  
    42  type checkCIRun struct {
    43  	commonFlags
    44  	baseCommit string
    45  	// TODO(atyfto): Fix the buildbucket library to return a BuilderID instead
    46  	// of a flag.
    47  	builder             buildbucket.BuilderIDFlag
    48  	buildStatus         int
    49  	searchRange         int
    50  	summaryMarkdownPath string
    51  	jsonOutput          string
    52  }
    53  
    54  func (c *checkCIRun) Init(defaultAuthOpts auth.Options) {
    55  	c.commonFlags.Init(defaultAuthOpts)
    56  	c.Flags.StringVar(&c.baseCommit, "base-commit", "", "Base commit as sha1.")
    57  	c.Flags.Var(&c.builder, "builder", "Fully-qualified Buildbucket CI builder name to inspect.")
    58  	c.Flags.IntVar(&c.buildStatus, "build-status", 20, "Build status to filter on. Defaults to FAILURE.")
    59  	c.Flags.IntVar(&c.searchRange, "search-range", 10, "Inspect up to this many recent builds.")
    60  	c.Flags.StringVar(&c.summaryMarkdownPath, "summary-markdown-path", "", "Path to summary markdown input file.")
    61  	c.Flags.StringVar(&c.jsonOutput, "json-output", "-", "Path to output finding to.")
    62  }
    63  
    64  func (c *checkCIRun) Parse() error {
    65  	if err := c.commonFlags.Parse(); err != nil {
    66  		return err
    67  	}
    68  	if c.baseCommit == "" {
    69  		return errors.New("-base-commit is required")
    70  	}
    71  	if &c.builder == nil {
    72  		return errors.New("-builder is required")
    73  	}
    74  	if c.summaryMarkdownPath == "" {
    75  		return errors.New("-summary-markdown-path is required")
    76  	}
    77  	if c.jsonOutput == "" {
    78  		return errors.New("-json-output is required")
    79  	}
    80  	return nil
    81  }
    82  
    83  // checkCI is the main algorithm which backs this subcommand.
    84  //
    85  // Given a git log and a list of builds, return a summary similarity finding.
    86  // This function assumes the list of builds is "CI-like": the builds should
    87  // be in a git history order.
    88  //
    89  // The `comparator` argument is a text-based comparator that returns a
    90  // similarity score between the input summary markdown and a build's summary
    91  // markdown.
    92  func checkCI(log []*git.Commit, builds []*buildbucketpb.Build, status buildbucketpb.Status, summaryMarkdown string, comparator compare.TextComparator) *findings.SummarySimilarity {
    93  	// Create a dual-purpose commit map for efficient existence checks and
    94  	// commit distance computation.
    95  	commitMap := make(map[string]int)
    96  	for idx, commit := range log {
    97  		commitMap[commit.Id] = idx
    98  	}
    99  	// Search the list of builds for a matching status.
   100  	for _, build := range builds {
   101  		// Ignore builds which aren't in the base build's log, i.e. equal to
   102  		// the base commit or older.
   103  		if _, ok := commitMap[build.Input.GitilesCommit.Id]; !ok {
   104  			continue
   105  		}
   106  		// If we encounter a green build, short-circuit immediately and return
   107  		// a finding which indicates as such.
   108  		if build.Status == buildbucketpb.Status_SUCCESS {
   109  			return &findings.SummarySimilarity{
   110  				BuildId: strconv.FormatInt(build.Id, 10),
   111  				// Commit distance is the commit's position in the log.
   112  				CommitDist: commitMap[build.Input.GitilesCommit.Id],
   113  				IsGreen:    true,
   114  			}
   115  		}
   116  		// If we encounter a build with a matching status, compute the summary
   117  		// markdown similarity.
   118  		if build.Status == status {
   119  			return &findings.SummarySimilarity{
   120  				BuildId: strconv.FormatInt(build.Id, 10),
   121  				// Commit distance is the commit's position in the log.
   122  				CommitDist: commitMap[build.Input.GitilesCommit.Id],
   123  				Score:      comparator.Compare(build.SummaryMarkdown, summaryMarkdown),
   124  			}
   125  		}
   126  	}
   127  	// If we've exhausted the search, we do not have a finding.
   128  	return nil
   129  }
   130  
   131  func (c *checkCIRun) main() error {
   132  	ctx := context.Background()
   133  	authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.parsedAuthOpts).Client()
   134  	if err != nil {
   135  		return fmt.Errorf("failed to initialize auth client: %w", err)
   136  	}
   137  
   138  	// Fetch the last -search-range completed builds of -builder.
   139  	buildsClient := buildbucketpb.NewBuildsPRPCClient(&prpc.Client{
   140  		C:    authClient,
   141  		Host: c.bbHost,
   142  	})
   143  	// TODO(atyfto): Fix the `buildbucket` library so we can get rid of this
   144  	// type assertion.
   145  	builder, ok := c.builder.Get().(*buildbucketpb.BuilderID)
   146  	if !ok {
   147  		return errors.New("builder input is invalid")
   148  	}
   149  	resp, err := buildsClient.SearchBuilds(ctx, &buildbucketpb.SearchBuildsRequest{
   150  		Predicate: &buildbucketpb.BuildPredicate{
   151  			Builder: builder,
   152  			Status:  buildbucketpb.Status_ENDED_MASK,
   153  		},
   154  		Fields: &field_mask.FieldMask{Paths: []string{
   155  			"builds.*.id",
   156  			"builds.*.input.gitiles_commit",
   157  			"builds.*.status",
   158  			"builds.*.summary_markdown",
   159  		}},
   160  		PageSize: int32(c.searchRange),
   161  	})
   162  	if err != nil {
   163  		return fmt.Errorf("failed to query builds for %s: %w", c.builder.String(), err)
   164  	}
   165  	builds := resp.Builds
   166  	if len(builds) == 0 {
   167  		return fmt.Errorf("no builds found for %s", c.builder.String())
   168  	}
   169  
   170  	// Grab the git log. Since we cannot know upfront how many commits will
   171  	// capture the entire range of builds, fetch a large multiplier of the
   172  	// search range.
   173  	gitilesHost := builds[0].Input.GitilesCommit.Host
   174  	gitilesProject := builds[0].Input.GitilesCommit.Project
   175  	gitilesClient, err := gitiles.NewClient(gitilesHost, gitilesProject, authClient)
   176  	if err != nil {
   177  		return fmt.Errorf("failed to initialize gitiles client for %s/%s: %w", gitilesHost, gitilesProject, err)
   178  	}
   179  	log, err := gitilesClient.Log(ctx, c.baseCommit, int32(c.searchRange*20))
   180  	if err != nil {
   181  		return fmt.Errorf("failed to fetch git log for %s: %w", c.baseCommit, err)
   182  	}
   183  
   184  	// Run the main algorithm.
   185  	summaryMarkdown, err := os.ReadFile(c.summaryMarkdownPath)
   186  	if err != nil {
   187  		return fmt.Errorf("could not read summary markdown input: %w", err)
   188  	}
   189  	ss := checkCI(log, builds, buildbucketpb.Status(c.buildStatus), string(summaryMarkdown), compare.LevenshteinComparator{Opts: levenshtein.DefaultOptions})
   190  
   191  	// Emit summary similarity finding to -json-output.
   192  	out := os.Stdout
   193  	if c.jsonOutput != "-" {
   194  		out, err = os.Create(c.jsonOutput)
   195  		if err != nil {
   196  			return err
   197  		}
   198  		defer out.Close()
   199  	}
   200  	if err := json.NewEncoder(out).Encode(ss); err != nil {
   201  		return fmt.Errorf("failed to encode: %w", err)
   202  	}
   203  	return nil
   204  }
   205  
   206  func (c *checkCIRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   207  	if err := c.Parse(); err != nil {
   208  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   209  		return 1
   210  	}
   211  
   212  	if err := c.main(); err != nil {
   213  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   214  		return 1
   215  	}
   216  	return 0
   217  }