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  }