go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/led/ledcli/edit_cl.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 ledcli
    16  
    17  import (
    18  	"context"
    19  	"net/http"
    20  	"net/url"
    21  	"strconv"
    22  	"strings"
    23  
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/grpc/status"
    26  
    27  	"github.com/maruel/subcommands"
    28  
    29  	"go.chromium.org/luci/auth"
    30  	bbpb "go.chromium.org/luci/buildbucket/proto"
    31  	gerritapi "go.chromium.org/luci/common/api/gerrit"
    32  	"go.chromium.org/luci/common/errors"
    33  	"go.chromium.org/luci/common/logging"
    34  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    35  	"go.chromium.org/luci/led/job"
    36  )
    37  
    38  func editGerritCLCmd(opts cmdBaseOptions) *subcommands.Command {
    39  	return &subcommands.Command{
    40  		UsageLine: "edit-gerrit-cl [-remove|-no-implicit-clear] URL_TO_CHANGELIST",
    41  		ShortDesc: "sets Gerrit CL-related properties on this JobDefinition (for experimenting with tryjobs)",
    42  		LongDesc: `This allows you to edit a JobDefinition and associate a changelist with
    43  it, as if the job was triggered via Gerrit.
    44  
    45  Recognized URL forms:
    46  	https://<gerrit_host>/<change>
    47  	https://<gerrit_host>/c/<path/to/project>/+/<change>
    48  	https://<gerrit_host>/c/<path/to/project>/+/<change>/<patchset>
    49  
    50  If you provide URLs in one of the first two forms and <gerrit_host> has public read
    51  access, this will fill in the missing information for the change. Otherwise, this will
    52  fail and ask you to provide the full URL containing host, project, change and patchset.
    53  
    54  By default, when adding a CL, this will clear all existing CLs on the job, unless
    55  you pass -no-implicit-clear. Most jobs only expect one CL, so this implicit clearing
    56  behavior is for CLI ergonomic reasons.
    57  `,
    58  
    59  		CommandRun: func() subcommands.CommandRun {
    60  			ret := &cmdEditCl{}
    61  			ret.initFlags(opts)
    62  			return ret
    63  		},
    64  	}
    65  }
    66  
    67  func editCrCLCmd(opts cmdBaseOptions) *subcommands.Command {
    68  	return &subcommands.Command{
    69  		UsageLine: "edit-cr-cl [-remove|-no-implicit-clear] URL_TO_CHANGELIST",
    70  		ShortDesc: "DEPRECATED: sets Gerrit CL-related properties on this JobDefinition (for experimenting with tryjobs)",
    71  		LongDesc: `This command functions identically to edit-gerrit-cl but has a Chrome-specific
    72  name for historical reasons. It's kept for backwards compatibility. Prefer to use edit-gerrit-cl instead.
    73  `,
    74  		Advanced: true,
    75  		CommandRun: func() subcommands.CommandRun {
    76  			ret := &cmdEditCl{}
    77  			ret.initFlags(opts)
    78  			ret.printDeprecationWarning = true
    79  			return ret
    80  		},
    81  	}
    82  }
    83  
    84  type cmdEditCl struct {
    85  	cmdBase
    86  
    87  	gerritChange            *bbpb.GerritChange
    88  	remove                  bool
    89  	noImplicitClear         bool
    90  	printDeprecationWarning bool
    91  }
    92  
    93  func (c *cmdEditCl) initFlags(opts cmdBaseOptions) {
    94  	c.Flags.BoolVar(&c.remove, "remove", false, "If provided, will remove the given CL instead of adding it.")
    95  	c.Flags.BoolVar(&c.noImplicitClear, "no-implicit-clear", false,
    96  		"If provided, will not clear existing CLs when adding a new one.")
    97  	c.cmdBase.initFlags(opts)
    98  }
    99  
   100  func (c *cmdEditCl) jobInput() bool                  { return true }
   101  func (c *cmdEditCl) positionalRange() (min, max int) { return 1, 1 }
   102  
   103  type changeResolver func(host string, change int64) (proj string, ps int64, err error)
   104  
   105  func parseCrChangeListURL(clURL string, resolveChange changeResolver) (*bbpb.GerritChange, error) {
   106  	p, err := url.Parse(clURL)
   107  	if err != nil {
   108  		return nil, errors.Annotate(err, "URL_TO_CHANGELIST").Err()
   109  	}
   110  	p.Host = strings.ReplaceAll(p.Host, ".git.corp.google.com", ".googlesource.com")
   111  	if !strings.HasSuffix(p.Hostname(), "-review.googlesource.com") {
   112  		return nil, errors.New("only *-review.googlesource.com URLs are supported")
   113  	}
   114  
   115  	var toks []string
   116  	if trimPath := strings.Trim(p.Path, "/"); len(trimPath) > 0 {
   117  		toks = strings.Split(trimPath, "/")
   118  	}
   119  
   120  	if len(toks) == 0 {
   121  		// https://<gerrit_host>/#/c/<change>
   122  		// https://<gerrit_host>/#/c/<change>/<patchset>
   123  		return nil, errors.Reason("old/empty gerrit URL: %q", clURL).Err()
   124  	}
   125  
   126  	var projectToks []string
   127  	var changePatchsetToks []string
   128  
   129  	if toks[0] == "c" {
   130  		toks = toks[1:] // remove "c"
   131  		// toks ==                 v----------------------------------v
   132  		// https://<gerrit_host>/c/<change>
   133  		// https://<gerrit_host>/c/<change>/<patchset>
   134  		// https://<gerrit_host>/c/<project/path>/+/<change>
   135  		// https://<gerrit_host>/c/<project/path>/+/<change>/<patchset>
   136  		for i, tok := range toks {
   137  			if tok == "+" {
   138  				projectToks, changePatchsetToks = toks[:i], toks[i+1:]
   139  				break
   140  			}
   141  		}
   142  		if len(projectToks) == 0 {
   143  			return nil, errors.Reason("gerrit URL missing project: %q", clURL).Err()
   144  		}
   145  	} else if len(toks) == 1 {
   146  		// toks ==               v------v
   147  		// https://<gerrit_host>/<change>
   148  		changePatchsetToks = toks
   149  	} else {
   150  		return nil, errors.Reason("Unknown changelist URL format: %q", clURL).Err()
   151  	}
   152  
   153  	if len(changePatchsetToks) == 0 {
   154  		return nil, errors.Reason("gerrit URL missing change/patchset: %q", clURL).Err()
   155  	}
   156  
   157  	ret := &bbpb.GerritChange{
   158  		Host:    p.Hostname(),
   159  		Project: strings.Join(projectToks, "/"),
   160  	}
   161  	ret.Change, err = strconv.ParseInt(changePatchsetToks[0], 10, 64)
   162  	if err != nil {
   163  		return nil, errors.Reason("gerrit URL parsing change %q from %q", changePatchsetToks[0], clURL).Err()
   164  	}
   165  	if len(changePatchsetToks) > 1 {
   166  		ret.Patchset, err = strconv.ParseInt(changePatchsetToks[1], 10, 64)
   167  		if err != nil {
   168  			return nil, errors.Reason("gerrit URL parsing patchset %q from %q", changePatchsetToks[1], clURL).Err()
   169  		}
   170  	} else {
   171  		ret.Project, ret.Patchset, err = resolveChange(ret.Host, ret.Change)
   172  		if err != nil {
   173  			return nil, errors.Annotate(
   174  				err, "resolving patchset from Gerrit Url %q", clURL).Err()
   175  		}
   176  	}
   177  
   178  	return ret, nil
   179  }
   180  
   181  func gerritResolver(ctx context.Context) changeResolver {
   182  	return func(host string, change int64) (string, int64, error) {
   183  		// TODO(crbug/1211623): allow authentication for internal hosts.
   184  		gc, err := gerritapi.NewRESTClient(http.DefaultClient, host, false)
   185  		if err != nil {
   186  			return "", 0, errors.Annotate(err, "creating new gerrit client").Err()
   187  		}
   188  		ci, err := gc.GetChange(ctx, &gerritpb.GetChangeRequest{
   189  			Number: change,
   190  			Options: []gerritpb.QueryOption{
   191  				gerritpb.QueryOption_CURRENT_REVISION,
   192  			},
   193  		})
   194  		if status.Code(err) == codes.Unauthenticated {
   195  			return "", 0, errors.Annotate(err,
   196  				"Gerrit host %q requires authentication and URL did not include project and/or patchset. "+
   197  					"Please include the project and patchset you want in your URL (patchset can be "+
   198  					"ignored by setting it to `0`).", host,
   199  			).Err()
   200  		}
   201  		if err != nil {
   202  			return "", 0, errors.Annotate(err, "GetChange").Err()
   203  		}
   204  
   205  		// There's only one.
   206  		for _, rd := range ci.Revisions {
   207  			return ci.Project, int64(rd.Number), nil
   208  		}
   209  		panic("impossible")
   210  	}
   211  }
   212  
   213  func (c *cmdEditCl) validateFlags(ctx context.Context, positionals []string, _ subcommands.Env) (err error) {
   214  	if c.remove && c.noImplicitClear {
   215  		return errors.New("cannot specify both -remove and -no-implicit-clear")
   216  	}
   217  
   218  	c.gerritChange, err = parseCrChangeListURL(positionals[0], gerritResolver(ctx))
   219  	return errors.Annotate(err, "invalid URL_TO_CHANGESET").Err()
   220  }
   221  
   222  func (c *cmdEditCl) execute(ctx context.Context, _ *http.Client, _ auth.Options, inJob *job.Definition) (out any, err error) {
   223  	if c.printDeprecationWarning {
   224  		logging.Warningf(ctx, "'edit-cr-cl' is a deprecated alias, please use 'edit-gerrit-cl'.")
   225  	}
   226  	return inJob, inJob.HighLevelEdit(func(je job.HighLevelEditor) {
   227  		if c.remove {
   228  			je.RemoveGerritChange(c.gerritChange)
   229  		} else {
   230  			if !c.noImplicitClear {
   231  				je.ClearGerritChanges()
   232  			}
   233  			je.AddGerritChange(c.gerritChange)
   234  		}
   235  	})
   236  }
   237  
   238  func (c *cmdEditCl) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   239  	return c.doContextExecute(a, c, args, env)
   240  }