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 }