blob: ad579deeb4bfb384824d0f91079551f192e73e3d [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package triager
import (
"errors"
"fmt"
"time"
cfgpb "go.chromium.org/luci/cv/api/config/v2"
"go.chromium.org/luci/cv/internal/changelist"
"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
"go.chromium.org/luci/cv/internal/run"
)
// maxAllowedDeps limits how many non-submitted deps a CL may have for CV to
// consider it.
//
// This applies to both singular and combinable modes.
// See also https://crbug.com/1217100.
const maxAllowedDeps = 240
// triagedDeps categorizes deps of a CL, referred to below as the "dependent"
// CL.
//
// Categories are exclusive. Non-submitted OK deps are not recorded here to
// avoid unnecessary allocations in the most common case, but they do affect
// the lastTriggered time.
type triagedDeps struct {
// lastCQVoteTriggered among *all* deps which are triggered. Can be Zero
//time if no dep is triggered.
lastCQVoteTriggered time.Time
// submitted are already submitted deps watched by this project, though not
// necessarily the same config group as the dependent CL. These deps are OK.
submitted []*changelist.Dep
// notYetLoaded means that more specific category isn't yet known.
notYetLoaded []*changelist.Dep
invalidDeps *changelist.CLError_InvalidDeps
}
// triageDeps triages deps of a PCL. See triagedDeps for documentation.
func triageDeps(pcl *prjpb.PCL, cgIndex int32, pm pmState) *triagedDeps {
cg := pm.ConfigGroup(cgIndex).Content
res := &triagedDeps{}
for _, dep := range pcl.GetDeps() {
dPCL := pm.PCL(dep.GetClid())
res.categorize(pcl, cgIndex, cg, dPCL, dep)
cqTrigger := dPCL.GetTriggers().GetCqVoteTrigger()
if cqTrigger != nil {
if tPB := cqTrigger.GetTime(); tPB != nil {
if t := tPB.AsTime(); res.lastCQVoteTriggered.IsZero() || res.lastCQVoteTriggered.Before(t) {
res.lastCQVoteTriggered = t
}
}
}
}
if okDeps := len(pcl.GetDeps()) - len(res.submitted); okDeps > maxAllowedDeps {
// Only declare this invalid if every non-submitted DEP is OK.
if res.invalidDeps == nil && len(res.notYetLoaded) == 0 {
res.ensureInvalidDeps()
res.invalidDeps.TooMany = &changelist.CLError_InvalidDeps_TooMany{
Actual: int32(okDeps),
MaxAllowed: maxAllowedDeps,
}
}
}
return res
}
// OK is true if triagedDeps doesn't have any not-OK deps.
func (t *triagedDeps) OK() bool {
return t.invalidDeps == nil
}
func (t *triagedDeps) makePurgeReason() *changelist.CLError {
if t.OK() {
panic("makePurgeReason must be called only iff !OK")
}
return &changelist.CLError{Kind: &changelist.CLError_InvalidDeps_{InvalidDeps: t.invalidDeps}}
}
// categorize adds dep to the applicable slice (if any).
//
// pcl is dependent PCL, which must be triggered.
// Its dep is represented by dPCL.
func (t *triagedDeps) categorize(pcl *prjpb.PCL, cgIndex int32, cg *cfgpb.ConfigGroup, dPCL *prjpb.PCL, dep *changelist.Dep) {
if dPCL == nil {
t.notYetLoaded = append(t.notYetLoaded, dep)
return
}
switch s := dPCL.GetStatus(); s {
case prjpb.PCL_UNKNOWN:
t.notYetLoaded = append(t.notYetLoaded, dep)
return
case prjpb.PCL_UNWATCHED, prjpb.PCL_DELETED:
// PCL deleted from Datastore should not happen outside of project
// re-enablement, so it's OK to treat the same as PCL_UNWATCHED for
// simplicity.
t.ensureInvalidDeps()
t.invalidDeps.Unwatched = append(t.invalidDeps.Unwatched, dep)
return
case prjpb.PCL_OK:
// Happy path; continue after the switch.
default:
panic(fmt.Errorf("unrecognized CL %d dep %d status %s", pcl.GetClid(), dPCL.GetClid(), s))
}
// CL is watched by this LUCI project.
if dPCL.GetSubmitted() {
// Submitted CL may no longer be in the expected ConfigGroup,
// but since it's in the same project, it's OK to refer to it as it
// doesn't create an information leak.
t.submitted = append(t.submitted, dep)
return
}
switch cgIndexes := dPCL.GetConfigGroupIndexes(); len(cgIndexes) {
case 0:
panic(fmt.Errorf("at least one ConfigGroup index required for watched dep PCL %d", dPCL.GetClid()))
case 1:
if cgIndexes[0] != cgIndex {
t.ensureInvalidDeps()
t.invalidDeps.WrongConfigGroup = append(t.invalidDeps.WrongConfigGroup, dep)
return
}
// Happy path; continue after the switch.
default:
// Strictly speaking, it may be OK iff dependentCGIndex is matched among
// other config groups. However, there is no compelling use-case for
// depending on a CL which matches several config groups. So, for
// compatibility with CQDaemon, be strict.
t.ensureInvalidDeps()
t.invalidDeps.WrongConfigGroup = append(t.invalidDeps.WrongConfigGroup, dep)
return
}
tr := pcl.GetTriggers().GetCqVoteTrigger()
dtr := dPCL.GetTriggers().GetCqVoteTrigger()
if cg.GetCombineCls() == nil {
t.categorizeSingle(tr, dep, cg)
} else {
t.categorizeCombinable(tr, dtr, dep)
}
}
func (t *triagedDeps) categorizeCombinable(tr, dtr *run.Trigger, dep *changelist.Dep) {
// During the `combine_cls.stabilization_delay` since the last triggered CL
// in a group, a user can change their mind. Since the full group of CLs
// isn't known here, categorization decision may or may not be final.
switch {
case dtr.GetMode() == tr.GetMode():
// Happy path.
return
case dtr == nil:
t.ensureInvalidDeps()
t.invalidDeps.CombinableUntriggered = append(t.invalidDeps.CombinableUntriggered, dep)
return
default:
// TODO(tandrii): support dry run on dependent and full Run on dep.
// For example, on a CL stack:
// CL | Mode
// D CQ+1
// C CQ+1
// B CQ+2
// A CQ+2
// (base) -
// D+C+B+A are can be dry-run-ed and B+A can be CQ+2ed at the same time
t.ensureInvalidDeps()
t.invalidDeps.CombinableMismatchedMode = append(t.invalidDeps.CombinableMismatchedMode, dep)
return
}
}
func (t *triagedDeps) categorizeSingle(tr *run.Trigger, dep *changelist.Dep, cg *cfgpb.ConfigGroup) {
// dependent is guaranteed non-nil.
switch mode := run.Mode(tr.GetMode()); mode {
case run.DryRun, run.QuickDryRun:
return // OK.
case run.FullRun:
if cg.GetVerifiers().GetGerritCqAbility().GetAllowSubmitWithOpenDeps() && dep.GetKind() == changelist.DepKind_HARD {
// If configured, allow CV to submit the entire stack (HARD deps
// only) of changes.
return
}
t.ensureInvalidDeps()
t.invalidDeps.SingleFullDeps = append(t.invalidDeps.SingleFullDeps, dep)
return
default:
panic(fmt.Errorf("unknown dependent mode %v", tr))
}
}
// ensureInvalidDeps initializes if necessary and returns .invalidDeps.
func (t *triagedDeps) ensureInvalidDeps() *changelist.CLError_InvalidDeps {
if t.invalidDeps == nil {
t.invalidDeps = &changelist.CLError_InvalidDeps{}
}
return t.invalidDeps
}
// iterateNotSubmitted calls clbk per each dep which isn't submitted.
//
// Must be called with the same PCL as was used to construct the triagedDeps.
func (t *triagedDeps) iterateNotSubmitted(pcl *prjpb.PCL, clbk func(dep *changelist.Dep)) {
// Because construction of triagedDeps is in order of PCL's Deps, the
// submitted must be a sub-sequence of Deps and we can compare just Dep
// pointers.
all, subs := pcl.GetDeps(), t.submitted
for {
switch {
case len(subs) == 0:
for _, dep := range all {
clbk(dep)
}
return
case len(all) == 0:
panic(errors.New("must not happen because submitted must be a subset of all deps (wrong PCL?)"))
default:
if all[0] == subs[0] {
subs = subs[1:]
} else {
clbk(all[0])
}
all = all[1:]
}
}
}