blob: a91d718b2745575d4bdc637cabc6892a5c5e3740 [file] [log] [blame]
package catapult
import (
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
apipb "go.chromium.org/luci/swarming/proto/api_v2"
"go.skia.org/infra/go/skerr"
skia_swarming "go.skia.org/infra/go/swarming"
"go.skia.org/infra/go/vcsinfo"
"go.skia.org/infra/pinpoint/go/bot_configs"
"go.skia.org/infra/pinpoint/go/compare"
"go.skia.org/infra/pinpoint/go/midpoint"
"go.skia.org/infra/pinpoint/go/workflows"
"go.skia.org/infra/pinpoint/go/workflows/internal"
pinpoint_proto "go.skia.org/infra/pinpoint/proto/v1"
"go.temporal.io/sdk/workflow"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
casUrlTemplate = "https://cas-viewer.appspot.com/%s/blobs/%s/%d/tree"
casIsolateHashTemplate = "%s/%d"
repositoryUrlTemplate = "%s/+/%s"
swarmingTaskUrlTemplate = "https://chrome-swarming.appspot.com/task?id=%s"
)
type commitFooters struct {
CommitBranch string
CommitPosition int32
ReviewUrl string
ChangeID string
}
// parseArguments parses a bisect request into a legacy reponse argument
func parseArguments(request *pinpoint_proto.ScheduleBisectRequest) (*pinpoint_proto.LegacyJobResponse_Argument, error) {
// Unsupported: ExtraTestArgs, Pin, BatchId, Trace
args := &pinpoint_proto.LegacyJobResponse_Argument{
ComparisonMode: request.GetComparisonMode(),
StartGitHash: request.GetStartGitHash(),
EndGitHash: request.GetEndGitHash(),
Configuration: request.GetConfiguration(),
Benchmark: request.GetBenchmark(),
Story: request.GetStory(),
StoryTags: request.GetStoryTags(),
Chart: request.GetChart(),
Statistic: request.GetStatistic(),
Project: request.GetProject(),
BugId: request.GetBugId(),
}
if request.GetBenchmark() != "" && request.GetConfiguration() != "" {
target, err := bot_configs.GetIsolateTarget(request.GetConfiguration(), request.GetBenchmark())
if err != nil {
return nil, skerr.Wrap(err)
}
args.Target = target
}
if request.GetInitialAttemptCount() != "" {
initAttemptCount, err := strconv.ParseInt(request.GetInitialAttemptCount(), 10, 32)
if err != nil {
return nil, skerr.Wrap(err)
}
args.InitialAttemptCount = int32(initAttemptCount)
}
if request.GetComparisonMagnitude() != "" {
comparisonMagnitude, err := strconv.ParseFloat(request.GetComparisonMagnitude(), 64)
if err != nil {
return nil, skerr.Wrap(err)
}
args.ComparisonMagnitude = comparisonMagnitude
}
if request.GetTags() != "" {
var tags map[string]string
if err := json.Unmarshal([]byte(request.GetTags()), &tags); err != nil {
return nil, skerr.Wrap(err)
}
args.Tags = tags
}
return args, nil
}
// convertImprovementDir converts the improvement direction from string to int.
// UP, DOWN, UNKNOWN = (0, 1, 4)
func parseImprovementDir(dir compare.ImprovementDir) int32 {
switch dir {
case compare.Up:
return 0
case compare.Down:
return 1
default:
return 4
}
}
func createBuildQuestDetail(commitRun *internal.BisectRun) *pinpoint_proto.LegacyJobResponse_State_Attempt_Execution {
return &pinpoint_proto.LegacyJobResponse_State_Attempt_Execution{
Completed: true,
Details: []*pinpoint_proto.LegacyJobResponse_State_Attempt_Execution_Detail{
{
Key: "builder",
Value: commitRun.Build.BuildChromeParams.Device,
},
{
Key: "isolate",
Value: fmt.Sprintf(casIsolateHashTemplate, commitRun.Build.CAS.Digest.Hash, commitRun.Build.CAS.Digest.SizeBytes),
Url: fmt.Sprintf(casUrlTemplate, commitRun.Build.CAS.CasInstance, commitRun.Build.CAS.Digest.Hash, commitRun.Build.CAS.Digest.SizeBytes),
},
},
}
}
func createTestQuestDetail(task *apipb.TaskResultResponse, benchmarkRun *workflows.TestRun) *pinpoint_proto.LegacyJobResponse_State_Attempt_Execution {
details := []*pinpoint_proto.LegacyJobResponse_State_Attempt_Execution_Detail{
{
Key: "bot",
Value: task.BotId,
},
{
Key: "task",
Value: task.TaskId,
Url: fmt.Sprintf(swarmingTaskUrlTemplate, task.TaskId),
},
}
iso_details := &pinpoint_proto.LegacyJobResponse_State_Attempt_Execution_Detail{
Key: "isolate",
}
if benchmarkRun.Status == skia_swarming.TASK_STATE_COMPLETED {
iso_details.Value = fmt.Sprintf(casIsolateHashTemplate, benchmarkRun.CAS.Digest.Hash, benchmarkRun.CAS.Digest.SizeBytes)
iso_details.Url = fmt.Sprintf(casUrlTemplate, benchmarkRun.CAS.CasInstance, benchmarkRun.CAS.Digest.Hash, benchmarkRun.CAS.Digest.SizeBytes)
}
details = append(details, iso_details)
resp := &pinpoint_proto.LegacyJobResponse_State_Attempt_Execution{
Completed: benchmarkRun.Status == skia_swarming.TASK_STATE_COMPLETED,
Details: details,
}
return resp
}
// parseRunData parses run data into a map of combined commit to list of attempts and a unique list of bots run for tests.
func parseRunData(ctx workflow.Context, runData []*internal.BisectRun, chart string) (map[uint32][]*pinpoint_proto.LegacyJobResponse_State_Attempt, []string, error) {
// use as set so we don't repeat keys
botSet := map[string]bool{}
commitToAttempts := map[uint32][]*pinpoint_proto.LegacyJobResponse_State_Attempt{}
// Each run has one Combined Commit, mapped to many attempts
for _, commitRun := range runData {
attempts := []*pinpoint_proto.LegacyJobResponse_State_Attempt{}
for _, benchmarkRun := range commitRun.Runs {
var task *apipb.TaskResultResponse
if err := workflow.ExecuteActivity(ctx, FetchTaskActivity, benchmarkRun.TaskID).Get(ctx, &task); err != nil {
return nil, nil, skerr.Wrapf(err, "failed to fetch task for parsing the bot id")
}
// This is to track list of all bots used for execution
botSet[task.BotId] = true
attempt := &pinpoint_proto.LegacyJobResponse_State_Attempt{
Executions: []*pinpoint_proto.LegacyJobResponse_State_Attempt_Execution{
createBuildQuestDetail(commitRun),
createTestQuestDetail(task, benchmarkRun),
// Get values detail is always empty for bisect
{
Completed: true,
Details: []*pinpoint_proto.LegacyJobResponse_State_Attempt_Execution_Detail{},
},
},
ResultValues: benchmarkRun.Values[chart],
}
attempts = append(attempts, attempt)
}
commitToAttempts[commitRun.Build.Commit.Key()] = attempts
}
// convert to list of keys from map
bots := make([]string, len(botSet))
idx := 0
for k := range botSet {
bots[idx] = k
idx++
}
return commitToAttempts, bots, nil
}
// parseResultValuesPerCommit converts combinedresults into an accessible map of combinedcommit's keys to its values.
//
// This assumes that result values are re-used, so for Combined Commits that appear multiple times in comparisons
// the values should be the same.
func parseResultValuesPerCommit(comparisons []*internal.CombinedResults) map[uint32][]float64 {
resp := map[uint32][]float64{}
for _, comparison := range comparisons {
// For writing back to Catapult, we're only really interested in Performance results.
// Functional comparisons don't set CommitPairValues. In other words, they remain nil.
if comparison.ResultType == internal.Performance {
resp[comparison.CommitPairValues.Lower.Commit.Key()] = comparison.CommitPairValues.Lower.Values
resp[comparison.CommitPairValues.Higher.Commit.Key()] = comparison.CommitPairValues.Higher.Values
}
}
return resp
}
// parseToSortedCombinedCommits sorts a list of commit pairs to a list of commits by commit time.
//
// This assumes that the list is curated by the bisection sequence, resulting in an order such as
// (A, Z), (A, M), (M, Z), (A, F), (F, M), (M, S), (S, Z) and so on. This would be sorted to
// (A, F, M, S, Z)
func parseToSortedCombinedCommits(comparisons []*internal.CombinedResults) []*midpoint.CombinedCommit {
if len(comparisons) < 1 {
return nil
}
sortedCombinedCommits := []*midpoint.CombinedCommit{
comparisons[0].CommitPairValues.Lower.Commit,
comparisons[0].CommitPairValues.Higher.Commit,
}
for idx := 1; idx < len(comparisons); idx++ {
comparison := comparisons[idx]
midIdx := len(sortedCombinedCommits) / 2
lowerCommit := comparison.CommitPairValues.Lower.Commit
lowerCommitKey := lowerCommit.Key()
higherCommit := comparison.CommitPairValues.Higher.Commit
higherCommitKey := higherCommit.Key()
if sortedCombinedCommits[midIdx-1].Key() == lowerCommitKey && sortedCombinedCommits[midIdx].Key() != higherCommitKey {
// Given (A M Z), lower is A and higher is E (not M), inject higher inbetween A and M.
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx, higherCommit)
} else if sortedCombinedCommits[midIdx].Key() == lowerCommitKey && sortedCombinedCommits[midIdx+1].Key() != higherCommitKey {
// Given (A M Z), lower is M and higher is Z, inject higher inbetween M and Z.
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx+1, higherCommit)
} else if midIdx+1 < len(sortedCombinedCommits) && sortedCombinedCommits[midIdx+1].Key() == lowerCommitKey && sortedCombinedCommits[midIdx+2].Key() != higherCommitKey {
// Given (A M Q U Z), lower is U and higher is not Z, inject higher inbetween U and Z.
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx+2, higherCommit)
} else if sortedCombinedCommits[midIdx-1].Key() == higherCommitKey && sortedCombinedCommits[midIdx-2].Key() != lowerCommitKey {
// Given (A D F M Z), higher is D and lower is not A so inject lower inbetween A and D
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx-1, lowerCommit)
} else if sortedCombinedCommits[midIdx].Key() == higherCommitKey && sortedCombinedCommits[midIdx-1].Key() != lowerCommitKey {
// Given (A D F M Z), higher is F and lower is not D so inject lower inbetween D and F
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx, lowerCommit)
} else if midIdx+1 < len(sortedCombinedCommits) && sortedCombinedCommits[midIdx+1].Key() == higherCommitKey && sortedCombinedCommits[midIdx].Key() != lowerCommitKey {
// Given (A D F M Z), higher is M and lower is not F so inject lower inbetween F and M
sortedCombinedCommits = slices.Insert(sortedCombinedCommits, midIdx+1, lowerCommit)
}
}
return sortedCombinedCommits
}
// Parse email from commit author string "{author full name} ({email})"
func parseEmail(author string) string {
p := strings.Split(author, " ")
return strings.Trim(p[len(p)-1], "()")
}
// parseFooters parses out all commit footers, split by new line and in the format key:value
func parseFooters(commitBody string) (*commitFooters, error) {
footers := &commitFooters{}
lines := strings.Split(commitBody, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Change-Id") {
parts := strings.Split(line, "Change-Id: ")
footers.ChangeID = parts[len(parts)-1]
} else if strings.HasPrefix(line, "Reviewed-on") {
parts := strings.Split(line, "Reviewed-on: ")
footers.ReviewUrl = parts[len(parts)-1]
} else if strings.HasPrefix(line, "Cr-Commit-Position") {
parts := strings.Split(line, "Cr-Commit-Position: ")
commitInfo := parts[len(parts)-1]
subParts := strings.Split(commitInfo, "@")
footers.CommitBranch = subParts[0]
position, err := strconv.ParseInt(strings.Trim(subParts[1], "{#}"), 10, 32)
if err != nil {
return nil, skerr.Wrap(err)
}
footers.CommitPosition = int32(position)
}
}
return footers, nil
}
// appendCommitData modifies the commit with information from gitiles
func appendCommitData(commit *pinpoint_proto.Commit, longCommit *vcsinfo.LongCommit) (*pinpoint_proto.Commit, error) {
commit.Url = fmt.Sprintf(repositoryUrlTemplate, commit.Repository, commit.GitHash)
commit.Author = parseEmail(longCommit.ShortCommit.Author)
commit.Created = timestamppb.New(longCommit.Timestamp)
commit.Subject = longCommit.ShortCommit.Subject
commit.Message = longCommit.Body
footers, err := parseFooters(longCommit.Body)
if err != nil {
return nil, skerr.Wrap(err)
}
commit.ChangeId = footers.ChangeID
commit.CommitBranch = footers.CommitBranch
commit.CommitPosition = footers.CommitPosition
commit.ReviewUrl = footers.ReviewUrl
return commit, nil
}
// parseCommitData returns a combined commit with all additional information filled (commit position, summary, author, etc.)
func parseCommitData(ctx workflow.Context, combinedCommit *midpoint.CombinedCommit) ([]*pinpoint_proto.Commit, error) {
commits := []*pinpoint_proto.Commit{}
// handle main first
var main *vcsinfo.LongCommit
if err := workflow.ExecuteActivity(ctx, FetchCommitActivity, combinedCommit.Main).Get(ctx, &main); err != nil {
return nil, skerr.Wrap(err)
}
mainCommit, err := appendCommitData(combinedCommit.Main, main)
if err != nil {
return nil, skerr.Wrap(err)
}
commits = append(commits, mainCommit)
// add each modified dep as a commit of its own
for _, modifiedDep := range combinedCommit.ModifiedDeps {
var dep *vcsinfo.LongCommit
if err := workflow.ExecuteActivity(ctx, FetchCommitActivity, modifiedDep).Get(ctx, &dep); err != nil {
return nil, skerr.Wrap(err)
}
depCommit, err := appendCommitData(modifiedDep, dep)
if err != nil {
return nil, skerr.Wrap(err)
}
commits = append(commits, depCommit)
}
return commits, nil
}
// parseRawDataToLegacyObject does the heavy lifting of converting all the raw run data to objects needed for the LegacyJobResponse.
//
// returns a list of JobState objects (curated by both comparison data and run data), a list of bots used for test runs and error if any.
func parseRawDataToLegacyObject(ctx workflow.Context, comparisons []*internal.CombinedResults, runData []*internal.BisectRun, chart string) ([]*pinpoint_proto.LegacyJobResponse_State, []string, error) {
states := []*pinpoint_proto.LegacyJobResponse_State{}
// runData is parsed into:
// - a map of combinedcommit keys to attempts. each commit that's analyzed is mapped to one state object,
// and this allows us to fetch all attempt data for every commit that's analyzed.
// - a unique list of bots that all attempts ran on, which is propagated back to the root
// response object.
commitToAttempts, bots, err := parseRunData(ctx, runData, chart)
if err != nil {
return nil, nil, skerr.Wrap(err)
}
commitsToValues := parseResultValuesPerCommit(comparisons)
combinedCommitsInOrder := parseToSortedCombinedCommits(comparisons)
for _, combinedCommit := range combinedCommitsInOrder {
state := &pinpoint_proto.LegacyJobResponse_State{}
attempts, ok := commitToAttempts[combinedCommit.Key()]
if !ok {
return nil, nil, skerr.Fmt("Cannot find combined commit %v from calculated attempts", combinedCommit)
}
state.Attempts = attempts
values, ok := commitsToValues[combinedCommit.Key()]
if !ok {
return nil, nil, skerr.Fmt("Cannot find combined commit %v from list of values", combinedCommit)
}
state.Values = values
commits, err := parseCommitData(ctx, combinedCommit)
if err != nil {
return nil, nil, skerr.Wrap(err)
}
state.Change = &pinpoint_proto.LegacyJobResponse_State_Change{
Commits: commits,
}
states = append(states, state)
}
return states, bots, nil
}