| // 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 |
| } |