go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/rpc/query_blamelist.go (about) 1 // Copyright 2020 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 rpc 16 17 import ( 18 "context" 19 "encoding/base64" 20 "sync" 21 22 "go.chromium.org/luci/auth/identity" 23 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 24 "go.chromium.org/luci/buildbucket/protoutil" 25 "go.chromium.org/luci/common/errors" 26 gitpb "go.chromium.org/luci/common/proto/git" 27 "go.chromium.org/luci/common/proto/gitiles" 28 "go.chromium.org/luci/common/sync/parallel" 29 "go.chromium.org/luci/gae/service/datastore" 30 "go.chromium.org/luci/grpc/appstatus" 31 "go.chromium.org/luci/milo/internal/model" 32 "go.chromium.org/luci/milo/internal/model/milostatus" 33 "go.chromium.org/luci/milo/internal/projectconfig" 34 "go.chromium.org/luci/milo/internal/utils" 35 milopb "go.chromium.org/luci/milo/proto/v1" 36 "go.chromium.org/luci/server/auth" 37 "google.golang.org/grpc/codes" 38 "google.golang.org/protobuf/proto" 39 ) 40 41 var queryBlamelistPageSize = PageSizeLimiter{ 42 Max: 1000, 43 Default: 100, 44 } 45 46 // QueryBlamelist implements milopb.MiloInternal service 47 func (s *MiloInternalService) QueryBlamelist(ctx context.Context, req *milopb.QueryBlamelistRequest) (_ *milopb.QueryBlamelistResponse, err error) { 48 startRev, err := prepareQueryBlamelistRequest(req) 49 if err != nil { 50 return nil, appstatus.BadRequest(err) 51 } 52 53 allowed, err := projectconfig.IsAllowed(ctx, req.GetBuilder().GetProject()) 54 if err != nil { 55 return nil, err 56 } 57 if !allowed { 58 if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity { 59 return nil, appstatus.Error(codes.Unauthenticated, "not logged in ") 60 } 61 return nil, appstatus.Error(codes.PermissionDenied, "no access to the project") 62 } 63 64 pageSize := int(queryBlamelistPageSize.Adjust(req.PageSize)) 65 66 // Fetch one more commit to check whether there are more commits in the 67 // blamelist. 68 gitilesClient, err := s.GetGitilesClient(ctx, req.GitilesCommit.Host, auth.AsCredentialsForwarder) 69 if err != nil { 70 return nil, err 71 } 72 logReq := &gitiles.LogRequest{ 73 Project: req.GitilesCommit.Project, 74 Committish: startRev, 75 PageSize: int32(pageSize + 1), 76 TreeDiff: true, 77 } 78 logRes, err := gitilesClient.Log(ctx, logReq) 79 if err != nil { 80 return nil, err 81 } 82 commits := logRes.Log 83 84 q := datastore.NewQuery("BuildSummary").Eq("BuilderID", utils.LegacyBuilderIDString(req.Builder)) 85 blameLength := len(commits) 86 m := sync.Mutex{} 87 88 // Find the first other commit that has an associated build and update 89 // blameLength. 90 err = parallel.WorkPool(8, func(c chan<- func() error) { 91 // Skip the first commit, it should always be included in the blamelist. 92 for i, commit := range commits[1:] { 93 newBlameLength := i + 1 // +1 since we skipped the first one. 94 95 m.Lock() 96 foundBuild := newBlameLength >= blameLength 97 m.Unlock() 98 99 // We have already found a build before this commit, no point looking 100 // further. 101 if foundBuild { 102 break 103 } 104 105 curGC := &buildbucketpb.GitilesCommit{Host: req.GitilesCommit.Host, Project: req.GitilesCommit.Project, Id: commit.Id} 106 c <- func() error { 107 // Check whether this commit has an associated build. 108 hasAssociatedBuild := false 109 err := datastore.Run(ctx, q.Eq("BlamelistPins", protoutil.GitilesBuildSet(curGC)), func(build *model.BuildSummary) error { 110 switch build.Summary.Status { 111 case milostatus.InfraFailure, milostatus.Expired, milostatus.Canceled: 112 return nil 113 default: 114 hasAssociatedBuild = true 115 return datastore.Stop 116 } 117 }) 118 if err != nil { 119 return err 120 } 121 122 if hasAssociatedBuild { 123 m.Lock() 124 if newBlameLength < blameLength { 125 blameLength = newBlameLength 126 } 127 m.Unlock() 128 } 129 return nil 130 } 131 } 132 }) 133 if err != nil { 134 return nil, err 135 } 136 137 // If there's more commits than needed, reserve the last commit as the pivot 138 // for the next page. 139 nextPageToken := "" 140 if blameLength >= pageSize+1 { 141 blameLength = pageSize 142 nextPageToken, err = serializeQueryBlamelistPageToken(&milopb.QueryBlamelistPageToken{ 143 NextCommitId: commits[blameLength].Id, 144 }) 145 if err != nil { 146 return nil, err 147 } 148 } 149 150 var precedingCommit *gitpb.Commit 151 if blameLength < len(commits) { 152 precedingCommit = commits[blameLength] 153 } 154 155 return &milopb.QueryBlamelistResponse{ 156 Commits: commits[:blameLength], 157 NextPageToken: nextPageToken, 158 PrecedingCommit: precedingCommit, 159 }, nil 160 } 161 162 // prepareQueryBlamelistRequest 163 // - validates the request params. 164 // - extracts start startRev from page token or gittles commit. 165 func prepareQueryBlamelistRequest(req *milopb.QueryBlamelistRequest) (startRev string, err error) { 166 switch { 167 case req.PageSize < 0: 168 return "", errors.Reason("page_size can not be negative").Err() 169 case req.GitilesCommit == nil: 170 return "", errors.Reason("gitiles_commit is required").Err() 171 case req.GitilesCommit.Host == "": 172 return "", errors.Reason("gitiles_commit.host is required").Err() 173 case req.GitilesCommit.Project == "": 174 return "", errors.Reason("gitiles_commit.project is required").Err() 175 case req.GitilesCommit.Id == "" && req.GitilesCommit.Ref == "": 176 return "", errors.Reason("either gitiles_commit.id or gitiles_commit.ref needs to be specified").Err() 177 } 178 179 if err := protoutil.ValidateRequiredBuilderID(req.Builder); err != nil { 180 return "", errors.Annotate(err, "builder").Err() 181 } 182 183 if req.PageToken != "" { 184 token, err := parseQueryBlamelistPageToken(req.PageToken) 185 if err != nil { 186 return "", errors.Annotate(err, "unable to parse page_token").Err() 187 } 188 return token.NextCommitId, nil 189 } 190 191 if req.GitilesCommit.Id == "" { 192 return req.GitilesCommit.Ref, nil 193 } 194 195 return req.GitilesCommit.Id, nil 196 } 197 198 func parseQueryBlamelistPageToken(tokenStr string) (token *milopb.QueryBlamelistPageToken, err error) { 199 bytes, err := base64.StdEncoding.DecodeString(tokenStr) 200 if err != nil { 201 return nil, err 202 } 203 token = &milopb.QueryBlamelistPageToken{} 204 err = proto.Unmarshal(bytes, token) 205 return 206 } 207 208 func serializeQueryBlamelistPageToken(token *milopb.QueryBlamelistPageToken) (string, error) { 209 bytes, err := proto.Marshal(token) 210 return base64.StdEncoding.EncodeToString(bytes), err 211 }