go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/bisection/util/protoutil/proto_util.go (about)

     1  // Copyright 2023 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 protoutil contains the utility functions to convert to protobuf.
    16  package protoutil
    17  
    18  import (
    19  	"context"
    20  	"strconv"
    21  
    22  	"google.golang.org/protobuf/types/known/timestamppb"
    23  
    24  	"go.chromium.org/luci/bisection/model"
    25  	pb "go.chromium.org/luci/bisection/proto/v1"
    26  	"go.chromium.org/luci/bisection/testfailureanalysis/bisection"
    27  	"go.chromium.org/luci/bisection/util/changelogutil"
    28  	"go.chromium.org/luci/bisection/util/datastoreutil"
    29  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/proto/mask"
    32  )
    33  
    34  // TestFailureAnalysisToPb converts model.TestFailureAnalysis to pb.TestAnalysis
    35  func TestFailureAnalysisToPb(ctx context.Context, tfa *model.TestFailureAnalysis, tfaMask *mask.Mask) (*pb.TestAnalysis, error) {
    36  	result := &pb.TestAnalysis{}
    37  	if tfaMask.MustIncludes("analysis_id") == mask.IncludeEntirely {
    38  		result.AnalysisId = tfa.ID
    39  	}
    40  	if tfaMask.MustIncludes("created_time") == mask.IncludeEntirely {
    41  		result.CreatedTime = timestamppb.New(tfa.CreateTime)
    42  	}
    43  	if tfaMask.MustIncludes("status") == mask.IncludeEntirely {
    44  		result.Status = tfa.Status
    45  	}
    46  	if tfaMask.MustIncludes("run_status") == mask.IncludeEntirely {
    47  		result.RunStatus = tfa.RunStatus
    48  	}
    49  	if tfaMask.MustIncludes("sample_bbid") == mask.IncludeEntirely {
    50  		result.SampleBbid = tfa.FailedBuildID
    51  	}
    52  	if tfaMask.MustIncludes("start_time") == mask.IncludeEntirely && tfa.HasStarted() {
    53  		result.StartTime = timestamppb.New(tfa.StartTime)
    54  	}
    55  	if tfaMask.MustIncludes("end_time") == mask.IncludeEntirely && tfa.HasEnded() {
    56  		result.EndTime = timestamppb.New(tfa.EndTime)
    57  	}
    58  	// It doesn't make sense to return builder information partially.
    59  	// We don't check mask.IncludePartially here.
    60  	if tfaMask.MustIncludes("builder") == mask.IncludeEntirely {
    61  		result.Builder = &buildbucketpb.BuilderID{
    62  			Project: tfa.Project,
    63  			Bucket:  tfa.Bucket,
    64  			Builder: tfa.Builder,
    65  		}
    66  	}
    67  
    68  	// Get test bundle.
    69  	bundle, err := datastoreutil.GetTestFailureBundle(ctx, tfa)
    70  	if err != nil {
    71  		return nil, errors.Annotate(err, "get test failure bundle").Err()
    72  	}
    73  	includeTestFailures := tfaMask.MustIncludes("test_failures")
    74  	if includeTestFailures == mask.IncludeEntirely || includeTestFailures == mask.IncludePartially {
    75  		tfMask := tfaMask.MustSubmask("test_failures.*")
    76  		result.TestFailures = TestFailureBundleToPb(ctx, bundle, tfMask)
    77  	}
    78  	primary := bundle.Primary()
    79  
    80  	if tfaMask.MustIncludes("start_failure_rate") == mask.IncludeEntirely {
    81  		result.StartFailureRate = float32(primary.StartPositionFailureRate)
    82  	}
    83  	if tfaMask.MustIncludes("end_failure_rate") == mask.IncludeEntirely {
    84  		result.EndFailureRate = float32(primary.EndPositionFailureRate)
    85  	}
    86  	// It doesn't make sense to return commit information partially.
    87  	// We don't check mask.IncludePartially here.
    88  	if tfaMask.MustIncludes("start_commit") == mask.IncludeEntirely {
    89  		result.StartCommit = &buildbucketpb.GitilesCommit{
    90  			Host:     primary.Ref.GetGitiles().GetHost(),
    91  			Project:  primary.Ref.GetGitiles().GetProject(),
    92  			Ref:      primary.Ref.GetGitiles().GetRef(),
    93  			Id:       tfa.StartCommitHash,
    94  			Position: uint32(primary.RegressionStartPosition),
    95  		}
    96  	}
    97  	if tfaMask.MustIncludes("end_commit") == mask.IncludeEntirely {
    98  		result.EndCommit = &buildbucketpb.GitilesCommit{
    99  			Host:     primary.Ref.GetGitiles().GetHost(),
   100  			Project:  primary.Ref.GetGitiles().GetProject(),
   101  			Ref:      primary.Ref.GetGitiles().GetRef(),
   102  			Id:       tfa.EndCommitHash,
   103  			Position: uint32(primary.RegressionEndPosition),
   104  		}
   105  	}
   106  
   107  	nsa, err := datastoreutil.GetTestNthSectionForAnalysis(ctx, tfa)
   108  	if err != nil {
   109  		return nil, errors.Annotate(err, "get test nthsection for analysis").Err()
   110  	}
   111  	if nsa != nil {
   112  		includeNsa := tfaMask.MustIncludes("nth_section_result")
   113  		if includeNsa == mask.IncludeEntirely || includeNsa == mask.IncludePartially {
   114  			nsaMask := tfaMask.MustSubmask("nth_section_result")
   115  			nsaResult, err := NthSectionAnalysisToPb(ctx, tfa, nsa, primary.Ref, nsaMask)
   116  			if err != nil {
   117  				return nil, errors.Annotate(err, "nthsection analysis to pb").Err()
   118  			}
   119  			result.NthSectionResult = nsaResult
   120  		}
   121  		includeCulprit := tfaMask.MustIncludes("culprit")
   122  		if includeCulprit == mask.IncludeEntirely || includeCulprit == mask.IncludePartially {
   123  			culpritMask := tfaMask.MustSubmask("culprit")
   124  			culprit, err := datastoreutil.GetVerifiedCulpritForTestAnalysis(ctx, tfa)
   125  			if err != nil {
   126  				return nil, errors.Annotate(err, "get verified culprit").Err()
   127  			}
   128  			if culprit != nil {
   129  				culpritPb, err := CulpritToPb(ctx, culprit, nsa, culpritMask)
   130  				if err != nil {
   131  					return nil, errors.Annotate(err, "culprit to pb").Err()
   132  				}
   133  				result.Culprit = culpritPb
   134  			}
   135  		}
   136  	}
   137  	return result, nil
   138  }
   139  
   140  func NthSectionAnalysisToPb(ctx context.Context, tfa *model.TestFailureAnalysis, nsa *model.TestNthSectionAnalysis, sourceRef *pb.SourceRef, nsaMask *mask.Mask) (*pb.TestNthSectionAnalysisResult, error) {
   141  	result := &pb.TestNthSectionAnalysisResult{
   142  		StartTime: timestamppb.New(nsa.StartTime),
   143  	}
   144  	if nsaMask.MustIncludes("status") == mask.IncludeEntirely {
   145  		result.Status = nsa.Status
   146  	}
   147  	if nsaMask.MustIncludes("run_status") == mask.IncludeEntirely {
   148  		result.RunStatus = nsa.RunStatus
   149  	}
   150  	if nsaMask.MustIncludes("blame_list") == mask.IncludeEntirely {
   151  		result.BlameList = nsa.BlameList
   152  	}
   153  	if nsaMask.MustIncludes("start_time") == mask.IncludeEntirely {
   154  		result.StartTime = timestamppb.New(nsa.StartTime)
   155  	}
   156  	if nsaMask.MustIncludes("end_time") == mask.IncludeEntirely && nsa.HasEnded() {
   157  		result.EndTime = timestamppb.New(nsa.EndTime)
   158  	}
   159  
   160  	// Populate culprit.
   161  	includeSuspect := nsaMask.MustIncludes("suspect")
   162  	if (includeSuspect == mask.IncludeEntirely || includeSuspect == mask.IncludePartially) && nsa.CulpritKey != nil {
   163  		suspectMask := nsaMask.MustSubmask("suspect")
   164  		culprit, err := datastoreutil.GetSuspect(ctx, nsa.CulpritKey.IntID(), nsa.CulpritKey.Parent())
   165  		if err != nil {
   166  			return nil, errors.Annotate(err, "get suspect").Err()
   167  		}
   168  		culpritPb, err := CulpritToPb(ctx, culprit, nsa, suspectMask)
   169  		if err != nil {
   170  			return nil, errors.Annotate(err, "culprit to pb").Err()
   171  		}
   172  		result.Suspect = culpritPb
   173  	}
   174  
   175  	// TODO(nqmtuan): Support selecting subfields of reruns.
   176  	// However, we don't need them now.
   177  	if nsaMask.MustIncludes("reruns") == mask.IncludeEntirely {
   178  		reruns, err := datastoreutil.GetTestNthSectionReruns(ctx, nsa)
   179  		if err != nil {
   180  			return nil, errors.Annotate(err, "get test nthsection reruns").Err()
   181  		}
   182  		pbReruns := []*pb.TestSingleRerun{}
   183  		for _, rerun := range reruns {
   184  			pbRerun, err := testSingleRerunToPb(ctx, rerun, nsa)
   185  			if err != nil {
   186  				return nil, errors.Annotate(err, "test single rerun to pb: %d", rerun.ID).Err()
   187  			}
   188  			pbReruns = append(pbReruns, pbRerun)
   189  		}
   190  		result.Reruns = pbReruns
   191  	}
   192  
   193  	// Populate remaining range.
   194  	// remaining regression range should be returned as whole.
   195  	if nsaMask.MustIncludes("remaining_nth_section_range") == mask.IncludeEntirely && !nsa.HasEnded() {
   196  		snapshot, err := bisection.CreateSnapshot(ctx, nsa)
   197  		if err != nil {
   198  			return nil, errors.Annotate(err, "couldn't create snapshot").Err()
   199  		}
   200  		ff, lp, err := snapshot.GetCurrentRegressionRange()
   201  		// GetCurrentRegressionRange return error if the regression is invalid.
   202  		// It is not exactly an error, but just a state of the analysis.
   203  		if err == nil {
   204  			// GetCurrentRegressionRange returns a pair of indices from the Snapshot that contains the culprit.
   205  			// So to really get the last pass, we should add 1.
   206  			lp++
   207  			lpCommitID := ""
   208  
   209  			// Blamelist only contains the possible commits for culprit.
   210  			// So it may or may not contain last pass. It will contain
   211  			// last pass if the regression range has been narrowed down during bisection,
   212  			// and the original last pass has been updated.
   213  			// In case that the blamelist does not contain last pass, we should get it
   214  			// from LastPassCommit.
   215  			if lp < len(nsa.BlameList.Commits) {
   216  				lpCommitID = nsa.BlameList.Commits[lp].Commit
   217  			} else {
   218  				// Old data do not have LastPassCommit populated, so we will check here.
   219  				if nsa.BlameList.LastPassCommit != nil {
   220  					lpCommitID = nsa.BlameList.LastPassCommit.Commit
   221  				}
   222  			}
   223  			if lpCommitID != "" {
   224  				result.RemainingNthSectionRange = &pb.RegressionRange{
   225  					FirstFailed: &buildbucketpb.GitilesCommit{
   226  						Host:    sourceRef.GetGitiles().Host,
   227  						Project: sourceRef.GetGitiles().Project,
   228  						Ref:     sourceRef.GetGitiles().Ref,
   229  						Id:      nsa.BlameList.Commits[ff].Commit,
   230  					},
   231  					LastPassed: &buildbucketpb.GitilesCommit{
   232  						Host:    sourceRef.GetGitiles().Host,
   233  						Project: sourceRef.GetGitiles().Project,
   234  						Ref:     sourceRef.GetGitiles().Ref,
   235  						Id:      lpCommitID,
   236  					},
   237  				}
   238  			}
   239  		}
   240  	}
   241  
   242  	return result, nil
   243  }
   244  
   245  func CulpritToPb(ctx context.Context, culprit *model.Suspect, nsa *model.TestNthSectionAnalysis, culpritMask *mask.Mask) (*pb.TestCulprit, error) {
   246  	result := &pb.TestCulprit{}
   247  
   248  	if culpritMask.MustIncludes("commit") == mask.IncludeEntirely {
   249  		result.Commit = &buildbucketpb.GitilesCommit{
   250  			Host:     culprit.GitilesCommit.Host,
   251  			Project:  culprit.GitilesCommit.Project,
   252  			Ref:      culprit.GitilesCommit.Ref,
   253  			Id:       culprit.GitilesCommit.Id,
   254  			Position: culprit.GitilesCommit.Position,
   255  		}
   256  	}
   257  
   258  	if culpritMask.MustIncludes("review_url") == mask.IncludeEntirely {
   259  		result.ReviewUrl = culprit.ReviewUrl
   260  	}
   261  
   262  	if culpritMask.MustIncludes("review_title") == mask.IncludeEntirely {
   263  		result.ReviewTitle = culprit.ReviewTitle
   264  	}
   265  
   266  	// TODO (nqmtuan): Support selecting subfields of actions.
   267  	// However, we don't need them now.
   268  	if culpritMask.MustIncludes("culprit_action") == mask.IncludeEntirely {
   269  		result.CulpritAction = CulpritActionsForSuspect(culprit)
   270  	}
   271  
   272  	includeDetails := culpritMask.MustIncludes("verification_details")
   273  	if includeDetails == mask.IncludeEntirely || includeDetails == mask.IncludePartially {
   274  		detailsMask := culpritMask.MustSubmask("verification_details")
   275  		verificationDetails, err := testVerificationDetails(ctx, culprit, nsa, detailsMask)
   276  		if err != nil {
   277  			return nil, errors.Annotate(err, "test verification details").Err()
   278  		}
   279  		result.VerificationDetails = verificationDetails
   280  	}
   281  	return result, nil
   282  }
   283  
   284  func TestFailureBundleToPb(ctx context.Context, bundle *model.TestFailureBundle, mask *mask.Mask) []*pb.TestFailure {
   285  	result := []*pb.TestFailure{}
   286  	// Add primary test failure first, and the rest.
   287  	// Primary should not be nil here, because it is from GetTestFailureBundle.
   288  	primary := bundle.Primary()
   289  	result = append(result, testFailureToPb(ctx, primary, mask))
   290  	for _, tf := range bundle.Others() {
   291  		result = append(result, testFailureToPb(ctx, tf, mask))
   292  	}
   293  	return result
   294  }
   295  
   296  func testFailureToPb(ctx context.Context, tf *model.TestFailure, tfMask *mask.Mask) *pb.TestFailure {
   297  	result := &pb.TestFailure{}
   298  	if tfMask.MustIncludes("test_id") == mask.IncludeEntirely {
   299  		result.TestId = tf.TestID
   300  	}
   301  	if tfMask.MustIncludes("variant_hash") == mask.IncludeEntirely {
   302  		result.VariantHash = tf.VariantHash
   303  	}
   304  	if tfMask.MustIncludes("ref_hash") == mask.IncludeEntirely {
   305  		result.RefHash = tf.RefHash
   306  	}
   307  	if tfMask.MustIncludes("variant") == mask.IncludeEntirely {
   308  		result.Variant = tf.Variant
   309  	}
   310  	if tfMask.MustIncludes("is_diverged") == mask.IncludeEntirely {
   311  		result.IsDiverged = tf.IsDiverged
   312  	}
   313  	if tfMask.MustIncludes("is_primary") == mask.IncludeEntirely {
   314  		result.IsPrimary = tf.IsPrimary
   315  	}
   316  	if tfMask.MustIncludes("start_hour") == mask.IncludeEntirely {
   317  		result.StartHour = timestamppb.New(tf.StartHour)
   318  	}
   319  	return result
   320  }
   321  
   322  func testVerificationDetails(ctx context.Context, culprit *model.Suspect, nsa *model.TestNthSectionAnalysis, detailsMask *mask.Mask) (*pb.TestSuspectVerificationDetails, error) {
   323  	verificationDetails := &pb.TestSuspectVerificationDetails{}
   324  	if detailsMask.MustIncludes("status") == mask.IncludeEntirely {
   325  		verificationDetails.Status = verificationStatusToPb(culprit.VerificationStatus)
   326  	}
   327  
   328  	// TODO(nqmtuan): Support selecting subfields of reruns.
   329  	// However, we don't need them now.
   330  	if detailsMask.MustIncludes("suspect_rerun") == mask.IncludeEntirely || detailsMask.MustIncludes("parent_rerun") == mask.IncludeEntirely {
   331  		suspectRerun, parentRerun, err := datastoreutil.GetVerificationRerunsForTestCulprit(ctx, culprit)
   332  		if err != nil {
   333  			return nil, errors.Annotate(err, "get verification reruns for test culprit").Err()
   334  		}
   335  
   336  		if detailsMask.MustIncludes("suspect_rerun") == mask.IncludeEntirely && suspectRerun != nil {
   337  			verificationDetails.SuspectRerun, err = testSingleRerunToPb(ctx, suspectRerun, nsa)
   338  			if err != nil {
   339  				return nil, errors.Annotate(err, "suspect rerun to pb").Err()
   340  			}
   341  		}
   342  		if detailsMask.MustIncludes("parent_rerun") == mask.IncludeEntirely && parentRerun != nil {
   343  			verificationDetails.ParentRerun, err = testSingleRerunToPb(ctx, parentRerun, nsa)
   344  			if err != nil {
   345  				return nil, errors.Annotate(err, "parent rerun to pb").Err()
   346  			}
   347  		}
   348  	}
   349  	return verificationDetails, nil
   350  }
   351  
   352  func verificationStatusToPb(status model.SuspectVerificationStatus) pb.SuspectVerificationStatus {
   353  	switch status {
   354  	case model.SuspectVerificationStatus_Unverified:
   355  		return pb.SuspectVerificationStatus_UNVERIFIED
   356  	case model.SuspectVerificationStatus_VerificationScheduled:
   357  		return pb.SuspectVerificationStatus_VERIFICATION_SCHEDULED
   358  	case model.SuspectVerificationStatus_UnderVerification:
   359  		return pb.SuspectVerificationStatus_UNDER_VERIFICATION
   360  	case model.SuspectVerificationStatus_ConfirmedCulprit:
   361  		return pb.SuspectVerificationStatus_CONFIRMED_CULPRIT
   362  	case model.SuspectVerificationStatus_Vindicated:
   363  		return pb.SuspectVerificationStatus_VINDICATED
   364  	case model.SuspectVerificationStatus_VerificationError:
   365  		return pb.SuspectVerificationStatus_VERIFICATION_ERROR
   366  	case model.SuspectVerificationStatus_Canceled:
   367  		return pb.SuspectVerificationStatus_VERIFICATION_CANCELED
   368  	default:
   369  		return pb.SuspectVerificationStatus_SUSPECT_VERIFICATION_STATUS_UNSPECIFIED
   370  	}
   371  }
   372  
   373  func testSingleRerunToPb(ctx context.Context, rerun *model.TestSingleRerun, nsa *model.TestNthSectionAnalysis) (*pb.TestSingleRerun, error) {
   374  	result := &pb.TestSingleRerun{
   375  		Bbid:       rerun.ID,
   376  		CreateTime: timestamppb.New(rerun.CreateTime),
   377  		Commit: &buildbucketpb.GitilesCommit{
   378  			Host:    rerun.LUCIBuild.GitilesCommit.GetHost(),
   379  			Project: rerun.LUCIBuild.GitilesCommit.GetProject(),
   380  			Ref:     rerun.LUCIBuild.GitilesCommit.GetRef(),
   381  			Id:      rerun.LUCIBuild.GitilesCommit.GetId(),
   382  		},
   383  	}
   384  	if rerun.HasStarted() {
   385  		result.StartTime = timestamppb.New(rerun.StartTime)
   386  	}
   387  	if rerun.HasEnded() {
   388  		result.EndTime = timestamppb.New(rerun.EndTime)
   389  	}
   390  	if rerun.ReportTime.Unix() != 0 {
   391  		result.ReportTime = timestamppb.New(rerun.ReportTime)
   392  	}
   393  
   394  	index := changelogutil.FindCommitIndexInBlameList(rerun.GitilesCommit, nsa.BlameList)
   395  	// There is only one case where we cannot find the rerun in blamelist
   396  	// It is when the rerun is part of the culprit verification and is
   397  	// the "last pass" revision.
   398  	// In this case, we should continue.
   399  	if index != -1 {
   400  		result.Index = strconv.FormatInt(int64(index), 10)
   401  		result.Commit.Position = uint32(nsa.BlameList.Commits[index].Position)
   402  	}
   403  
   404  	// Update rerun results.
   405  	pbRerunResults, err := rerunResultsToPb(ctx, rerun.TestResults, rerun.Status)
   406  	if err != nil {
   407  		return nil, errors.Annotate(err, "rerun results to pb").Err()
   408  	}
   409  	result.RerunResult = pbRerunResults
   410  	return result, nil
   411  }
   412  
   413  func rerunResultsToPb(ctx context.Context, testResults model.RerunTestResults, status pb.RerunStatus) (*pb.RerunTestResults, error) {
   414  	pb := &pb.RerunTestResults{
   415  		RerunStatus: status,
   416  	}
   417  	if !testResults.IsFinalized {
   418  		return pb, nil
   419  	}
   420  	for _, singleResult := range testResults.Results {
   421  		pbSingleResult, err := rerunTestSingleResultToPb(ctx, singleResult)
   422  		if err != nil {
   423  			return nil, errors.Annotate(err, "rerun test single result to pb").Err()
   424  		}
   425  		pb.Results = append(pb.Results, pbSingleResult)
   426  	}
   427  	return pb, nil
   428  }
   429  
   430  func rerunTestSingleResultToPb(ctx context.Context, singleResult model.RerunSingleTestResult) (*pb.RerunTestSingleResult, error) {
   431  	pb := &pb.RerunTestSingleResult{
   432  		ExpectedCount:   singleResult.ExpectedCount,
   433  		UnexpectedCount: singleResult.UnexpectedCount,
   434  	}
   435  	tf, err := datastoreutil.GetTestFailure(ctx, singleResult.TestFailureKey.IntID())
   436  	if err != nil {
   437  		return nil, errors.Annotate(err, "get test failure").Err()
   438  	}
   439  	pb.TestId = tf.TestID
   440  	pb.VariantHash = tf.VariantHash
   441  	return pb, nil
   442  }
   443  
   444  func CulpritActionsForSuspect(suspect *model.Suspect) []*pb.CulpritAction {
   445  	culpritActions := []*pb.CulpritAction{}
   446  	if suspect.IsRevertCommitted {
   447  		// culprit action for auto-committing a revert
   448  		culpritActions = append(culpritActions, &pb.CulpritAction{
   449  			ActionType:  pb.CulpritActionType_CULPRIT_AUTO_REVERTED,
   450  			RevertClUrl: suspect.RevertURL,
   451  			ActionTime:  timestamppb.New(suspect.RevertCommitTime),
   452  		})
   453  	} else if suspect.IsRevertCreated {
   454  		// culprit action for creating a revert
   455  		culpritActions = append(culpritActions, &pb.CulpritAction{
   456  			ActionType:  pb.CulpritActionType_REVERT_CL_CREATED,
   457  			RevertClUrl: suspect.RevertURL,
   458  			ActionTime:  timestamppb.New(suspect.RevertCreateTime),
   459  		})
   460  	} else if suspect.HasSupportRevertComment {
   461  		// culprit action for commenting on an existing revert
   462  		culpritActions = append(culpritActions, &pb.CulpritAction{
   463  			ActionType:  pb.CulpritActionType_EXISTING_REVERT_CL_COMMENTED,
   464  			RevertClUrl: suspect.RevertURL,
   465  			ActionTime:  timestamppb.New(suspect.SupportRevertCommentTime),
   466  		})
   467  	} else if suspect.HasCulpritComment {
   468  		// culprit action for commenting on the culprit
   469  		culpritActions = append(culpritActions, &pb.CulpritAction{
   470  			ActionType: pb.CulpritActionType_CULPRIT_CL_COMMENTED,
   471  			ActionTime: timestamppb.New(suspect.CulpritCommentTime),
   472  		})
   473  	} else {
   474  		action := &pb.CulpritAction{
   475  			ActionType:     pb.CulpritActionType_NO_ACTION,
   476  			InactionReason: suspect.InactionReason,
   477  		}
   478  		if suspect.RevertURL != "" {
   479  			action.RevertClUrl = suspect.RevertURL
   480  		}
   481  		culpritActions = append(culpritActions, action)
   482  	}
   483  	return culpritActions
   484  }