go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/api/gerrit/rest.go (about)

     1  // Copyright 2018 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 gerrit
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/url"
    25  	"strconv"
    26  	"strings"
    27  
    28  	"golang.org/x/net/context/ctxhttp"
    29  	"google.golang.org/grpc"
    30  	"google.golang.org/grpc/codes"
    31  	"google.golang.org/grpc/status"
    32  	"google.golang.org/protobuf/types/known/emptypb"
    33  
    34  	"go.chromium.org/luci/common/clock"
    35  	"go.chromium.org/luci/common/errors"
    36  	"go.chromium.org/luci/common/logging"
    37  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    38  )
    39  
    40  const (
    41  	// OAuthScope is the OAuth 2.0 scope that must be included when acquiring an
    42  	// access token for Gerrit RPCs.
    43  	OAuthScope = "https://www.googleapis.com/auth/gerritcodereview"
    44  
    45  	// contentTypeJSON is the http header content-type value for json encoded
    46  	// objects in the body.
    47  	contentTypeJSON = "application/json; charset=UTF-8"
    48  
    49  	// contentTypeText is the http header content-type value for plain text body.
    50  	contentTypeText = "application/x-www-form-urlencoded; charset=UTF-8"
    51  
    52  	// defaultQueryLimit is the default limit for ListChanges, and
    53  	// maxQueryLimit is the maximum allowed limit. If either of these are
    54  	// changed, the proto comments should also be updated.
    55  	defaultQueryLimit = 25
    56  	maxQueryLimit     = 1000
    57  )
    58  
    59  // This file implements Gerrit proto service client on top of Gerrit REST API.
    60  // WARNING: The returned client is incomplete, so if you want access to a
    61  // particular field from this API, you may need to update the relevant struct
    62  // and add an unmarshalling of that field.
    63  
    64  // NewRESTClient creates a new Gerrit client based on Gerrit's REST API.
    65  //
    66  // The host must be a full Gerrit host, e.g. "chromium-review.googlesource.com".
    67  //
    68  // If `auth` is true, this indicates that the given HTTP client sends
    69  // authenticated requests. If so, the requests to Gerrit will include "/a/" URL
    70  // path prefix.
    71  //
    72  // RPC methods of the returned client return an error if a grpc.CallOption is
    73  // passed.
    74  func NewRESTClient(httpClient *http.Client, host string, auth bool) (gerritpb.GerritClient, error) {
    75  	if strings.Contains(host, "/") {
    76  		return nil, errors.Reason("invalid host %q", host).Err()
    77  	}
    78  	return &client{
    79  		hClient: httpClient,
    80  		auth:    auth,
    81  		host:    host,
    82  	}, nil
    83  }
    84  
    85  // Implementation.
    86  
    87  // jsonPrefix is expected in all JSON responses from the Gerrit REST API.
    88  var jsonPrefix = []byte(")]}'")
    89  
    90  // client implements gerritpb.GerritClient.
    91  type client struct {
    92  	hClient *http.Client
    93  
    94  	auth bool
    95  	host string
    96  	// testBaseURL overrides auth & host args in tests.
    97  	testBaseURL string
    98  }
    99  
   100  func (c *client) ListChanges(ctx context.Context, req *gerritpb.ListChangesRequest, opts ...grpc.CallOption) (*gerritpb.ListChangesResponse, error) {
   101  	limit := req.Limit
   102  	if req.Limit < 0 {
   103  		return nil, errors.Reason("field Limit %d must be nonnegative", req.Limit).Err()
   104  	} else if req.Limit == 0 {
   105  		limit = defaultQueryLimit
   106  	} else if req.Limit > maxQueryLimit {
   107  		return nil, errors.Reason("field Limit %d should be at most %d", req.Limit, maxQueryLimit).Err()
   108  	}
   109  	if req.Offset < 0 {
   110  		return nil, errors.Reason("field Offset %d must be nonnegative", req.Offset).Err()
   111  	}
   112  
   113  	params := url.Values{}
   114  	params.Add("q", req.Query)
   115  	for _, o := range req.Options {
   116  		params.Add("o", o.String())
   117  	}
   118  	params.Add("n", strconv.FormatInt(limit, 10))
   119  	params.Add("S", strconv.FormatInt(req.Offset, 10))
   120  
   121  	var changes []*changeInfo
   122  	if _, err := c.call(ctx, "GET", "/changes/", params, nil, &changes, opts); err != nil {
   123  		return nil, err
   124  	}
   125  
   126  	resp := &gerritpb.ListChangesResponse{}
   127  	for _, c := range changes {
   128  		p, err := c.ToProto()
   129  		if err != nil {
   130  			return nil, err
   131  		}
   132  		resp.Changes = append(resp.Changes, p)
   133  	}
   134  	if len(changes) > 0 {
   135  		resp.MoreChanges = changes[len(changes)-1].MoreChanges
   136  	}
   137  
   138  	return resp, nil
   139  }
   140  
   141  func (c *client) GetChange(ctx context.Context, req *gerritpb.GetChangeRequest, opts ...grpc.CallOption) (
   142  	*gerritpb.ChangeInfo, error) {
   143  	if err := checkArgs(opts, req); err != nil {
   144  		return nil, err
   145  	}
   146  
   147  	path := fmt.Sprintf("/changes/%s", gerritChangeIDForRouting(req.Number, req.Project))
   148  
   149  	params := url.Values{}
   150  	for _, o := range req.Options {
   151  		params.Add("o", o.String())
   152  	}
   153  	if meta := req.GetMeta(); meta != "" {
   154  		params.Add("meta", meta)
   155  	}
   156  
   157  	var resp changeInfo
   158  	if _, err := c.call(ctx, "GET", path, params, nil, &resp, opts); err != nil {
   159  		return nil, err
   160  	}
   161  	return resp.ToProto()
   162  }
   163  
   164  type changeInput struct {
   165  	Project    string `json:"project"`
   166  	Branch     string `json:"branch"`
   167  	Subject    string `json:"subject"`
   168  	BaseCommit string `json:"base_commit"`
   169  }
   170  
   171  func (c *client) CreateChange(ctx context.Context, req *gerritpb.CreateChangeRequest, opts ...grpc.CallOption) (*gerritpb.ChangeInfo, error) {
   172  	var resp changeInfo
   173  	data := &changeInput{
   174  		Project:    req.Project,
   175  		Branch:     req.Ref,
   176  		Subject:    req.Subject,
   177  		BaseCommit: req.BaseCommit,
   178  	}
   179  
   180  	if _, err := c.call(ctx, "POST", "/changes/", url.Values{}, data, &resp, opts, http.StatusCreated); err != nil {
   181  		return nil, errors.Annotate(err, "create empty change").Err()
   182  	}
   183  
   184  	switch ci, err := resp.ToProto(); {
   185  	case err != nil:
   186  		return nil, err
   187  	case ci.Status != gerritpb.ChangeStatus_NEW:
   188  		return nil, errors.Reason("unknown status %s for newly created change", ci.Status).Err()
   189  	default:
   190  		return ci, nil
   191  	}
   192  }
   193  
   194  func (c *client) ChangeEditFileContent(ctx context.Context, req *gerritpb.ChangeEditFileContentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
   195  	// Use QueryEscape instead of PathEscape since "+" can be ambigious
   196  	// (" " or "+") and it's not escaped in PathEscape.
   197  	path := fmt.Sprintf("/changes/%s/edit/%s", gerritChangeIDForRouting(req.Number, req.Project), url.QueryEscape(req.FilePath))
   198  	if _, _, err := c.callRaw(ctx, "PUT", path, url.Values{}, textInputHeaders(), req.Content, opts, http.StatusNoContent); err != nil {
   199  		return nil, errors.Annotate(err, "change edit file content").Err()
   200  	}
   201  	return &emptypb.Empty{}, nil
   202  }
   203  
   204  func (c *client) DeleteEditFileContent(ctx context.Context, req *gerritpb.DeleteEditFileContentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
   205  	// Use QueryEscape instead of PathEscape since "+" can be ambigious
   206  	// (" " or "+") and it's not escaped in PathEscape.
   207  	path := fmt.Sprintf("/changes/%s/edit/%s", gerritChangeIDForRouting(req.Number, req.Project), url.QueryEscape(req.FilePath))
   208  	var data struct{}
   209  	// The response cannot be JSON-deserialized.
   210  	if _, err := c.call(ctx, "DELETE", path, url.Values{}, &data, nil, opts, http.StatusNoContent); err != nil {
   211  		return nil, errors.Annotate(err, "delete edit file content").Err()
   212  	}
   213  	return &emptypb.Empty{}, nil
   214  }
   215  
   216  func (c *client) ChangeEditPublish(ctx context.Context, req *gerritpb.ChangeEditPublishRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
   217  	path := fmt.Sprintf("/changes/%s/edit:publish", gerritChangeIDForRouting(req.Number, req.Project))
   218  	if _, _, err := c.callRaw(ctx, "POST", path, url.Values{}, textInputHeaders(), []byte{}, opts, http.StatusNoContent); err != nil {
   219  		return nil, errors.Annotate(err, "change edit publish").Err()
   220  	}
   221  	return &emptypb.Empty{}, nil
   222  }
   223  
   224  func (c *client) AddReviewer(ctx context.Context, req *gerritpb.AddReviewerRequest, opts ...grpc.CallOption) (*gerritpb.AddReviewerResult, error) {
   225  	var resp addReviewerResult
   226  	data := &addReviewerRequest{
   227  		Reviewer:  req.Reviewer,
   228  		State:     enumToString(int32(req.State.Number()), gerritpb.AddReviewerRequest_State_name),
   229  		Confirmed: req.Confirmed,
   230  		Notify:    enumToString(int32(req.Notify.Number()), gerritpb.Notify_name),
   231  	}
   232  	path := fmt.Sprintf("/changes/%s/reviewers", gerritChangeIDForRouting(req.Number, req.Project))
   233  	if _, err := c.call(ctx, "POST", path, url.Values{}, data, &resp, opts); err != nil {
   234  		return nil, errors.Annotate(err, "add reviewers").Err()
   235  	}
   236  	rr, err := resp.ToProto()
   237  	if err != nil {
   238  		return nil, errors.Annotate(err, "decoding response").Err()
   239  	}
   240  	return rr, nil
   241  }
   242  
   243  func (c *client) DeleteReviewer(ctx context.Context, req *gerritpb.DeleteReviewerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
   244  	path := fmt.Sprintf("/changes/%s/reviewers/%s/delete", gerritChangeIDForRouting(req.Number, req.Project), url.PathEscape(req.AccountId))
   245  	if _, err := c.call(ctx, "POST", path, url.Values{}, nil, nil, opts, http.StatusNoContent); err != nil {
   246  		return nil, errors.Annotate(err, "delete reviewer").Err()
   247  	}
   248  	return &emptypb.Empty{}, nil
   249  }
   250  
   251  func (c *client) SetReview(ctx context.Context, in *gerritpb.SetReviewRequest, opts ...grpc.CallOption) (*gerritpb.ReviewResult, error) {
   252  	if err := checkArgs(opts, in); err != nil {
   253  		return nil, err
   254  	}
   255  	path := fmt.Sprintf("/changes/%s/revisions/%s/review", gerritChangeIDForRouting(in.Number, in.Project), in.RevisionId)
   256  	data := reviewInput{
   257  		Message:                          in.Message,
   258  		Tag:                              in.Tag,
   259  		Notify:                           enumToString(int32(in.Notify.Number()), gerritpb.Notify_name),
   260  		NotifyDetails:                    toNotifyDetails(in.GetNotifyDetails()),
   261  		OnBehalfOf:                       in.OnBehalfOf,
   262  		Ready:                            in.Ready,
   263  		WorkInProgress:                   in.WorkInProgress,
   264  		AddToAttentionSet:                toAttentionSetInputs(in.GetAddToAttentionSet()),
   265  		RemoveFromAttentionSet:           toAttentionSetInputs(in.GetRemoveFromAttentionSet()),
   266  		IgnoreAutomaticAttentionSetRules: in.IgnoreAutomaticAttentionSetRules,
   267  		Reviewers:                        toReviewerInputs(in.GetReviewers()),
   268  	}
   269  	if in.Labels != nil {
   270  		data.Labels = make(map[string]int32)
   271  		for k, v := range in.Labels {
   272  			data.Labels[k] = v
   273  		}
   274  	}
   275  	var resp reviewResult
   276  	if _, err := c.call(ctx, "POST", path, url.Values{}, &data, &resp, opts); err != nil {
   277  		return nil, errors.Annotate(err, "set review").Err()
   278  	}
   279  	rr, err := resp.ToProto()
   280  	if err != nil {
   281  		return nil, errors.Annotate(err, "decoding response").Err()
   282  	}
   283  	return rr, nil
   284  }
   285  
   286  func (c *client) AddToAttentionSet(ctx context.Context, req *gerritpb.AttentionSetRequest, opts ...grpc.CallOption) (*gerritpb.AccountInfo, error) {
   287  	if err := checkArgs(opts, req); err != nil {
   288  		return nil, err
   289  	}
   290  	path := fmt.Sprintf("/changes/%s/attention", gerritChangeIDForRouting(req.Number, req.Project))
   291  	data := toAttentionSetInput(req.GetInput())
   292  	var resp accountInfo
   293  	if _, err := c.call(ctx, "POST", path, url.Values{}, data, &resp, opts); err != nil {
   294  		return nil, errors.Annotate(err, "add to attention set").Err()
   295  	}
   296  	return resp.ToProto(), nil
   297  }
   298  
   299  func (c *client) SubmitChange(ctx context.Context, req *gerritpb.SubmitChangeRequest, opts ...grpc.CallOption) (*gerritpb.ChangeInfo, error) {
   300  	var resp changeInfo
   301  	path := fmt.Sprintf("/changes/%s/submit", gerritChangeIDForRouting(req.Number, req.Project))
   302  	var data struct{}
   303  	if _, err := c.call(ctx, "POST", path, url.Values{}, &data, &resp, opts); err != nil {
   304  		return nil, errors.Annotate(err, "submit change").Err()
   305  	}
   306  	return resp.ToProto()
   307  }
   308  
   309  func (c *client) RevertChange(ctx context.Context, req *gerritpb.RevertChangeRequest, opts ...grpc.CallOption) (*gerritpb.ChangeInfo, error) {
   310  	if err := checkArgs(opts, req); err != nil {
   311  		return nil, err
   312  	}
   313  
   314  	var resp changeInfo
   315  	path := fmt.Sprintf("/changes/%s/revert", gerritChangeIDForRouting(req.Number, req.Project))
   316  	data := map[string]string{
   317  		"message": req.Message,
   318  	}
   319  	if _, err := c.call(ctx, "POST", path, url.Values{}, &data, &resp, opts); err != nil {
   320  		return nil, errors.Annotate(err, "revert change").Err()
   321  	}
   322  	return resp.ToProto()
   323  }
   324  
   325  func (c *client) AbandonChange(ctx context.Context, req *gerritpb.AbandonChangeRequest, opts ...grpc.CallOption) (*gerritpb.ChangeInfo, error) {
   326  	var resp changeInfo
   327  	path := fmt.Sprintf("/changes/%s/abandon", gerritChangeIDForRouting(req.Number, req.Project))
   328  	data := map[string]string{
   329  		"message": req.Message,
   330  	}
   331  	if _, err := c.call(ctx, "POST", path, url.Values{}, &data, &resp, opts); err != nil {
   332  		return nil, errors.Annotate(err, "abandon change").Err()
   333  	}
   334  	return resp.ToProto()
   335  }
   336  
   337  func (c *client) SubmitRevision(ctx context.Context, req *gerritpb.SubmitRevisionRequest, opts ...grpc.CallOption) (*gerritpb.SubmitInfo, error) {
   338  	var resp submitInfo
   339  	path := fmt.Sprintf("/changes/%s/revisions/%s/submit", gerritChangeIDForRouting(req.Number, req.Project), req.RevisionId)
   340  	var data struct{}
   341  	if _, err := c.call(ctx, "POST", path, url.Values{}, &data, &resp, opts); err != nil {
   342  		return nil, errors.Annotate(err, "submit revision").Err()
   343  	}
   344  	return resp.ToProto(), nil
   345  }
   346  
   347  func (c *client) GetMergeable(ctx context.Context, in *gerritpb.GetMergeableRequest, opts ...grpc.CallOption) (*gerritpb.MergeableInfo, error) {
   348  	var resp mergeableInfo
   349  	path := fmt.Sprintf("/changes/%s/revisions/%s/mergeable", gerritChangeIDForRouting(in.Number, in.Project), in.RevisionId)
   350  	if _, err := c.call(ctx, "GET", path, url.Values{}, nil, &resp, opts); err != nil {
   351  		return nil, errors.Annotate(err, "get mergeable").Err()
   352  	}
   353  	return resp.ToProto()
   354  }
   355  
   356  func (c *client) ListFiles(ctx context.Context, req *gerritpb.ListFilesRequest, opts ...grpc.CallOption) (*gerritpb.ListFilesResponse, error) {
   357  	var resp map[string]fileInfo
   358  	params := url.Values{}
   359  	if req.Parent > 0 {
   360  		params.Add("parent", strconv.FormatInt(req.Parent, 10))
   361  	}
   362  	if req.SubstringQuery != "" {
   363  		params.Add("q", req.SubstringQuery)
   364  	}
   365  	if req.Base != "" {
   366  		params.Add("base", req.Base)
   367  	}
   368  	path := fmt.Sprintf("/changes/%s/revisions/%s/files/", gerritChangeIDForRouting(req.Number, req.Project), req.RevisionId)
   369  	if _, err := c.call(ctx, "GET", path, params, nil, &resp, opts); err != nil {
   370  		return nil, errors.Annotate(err, "list files").Err()
   371  	}
   372  	lfr := &gerritpb.ListFilesResponse{
   373  		Files: make(map[string]*gerritpb.FileInfo, len(resp)),
   374  	}
   375  	for k, v := range resp {
   376  		lfr.Files[k] = v.ToProto()
   377  	}
   378  	return lfr, nil
   379  }
   380  
   381  func (c *client) GetRelatedChanges(ctx context.Context, req *gerritpb.GetRelatedChangesRequest, opts ...grpc.CallOption) (*gerritpb.GetRelatedChangesResponse, error) {
   382  	// Example:
   383  	// https://chromium-review.googlesource.com/changes/1563638/revisions/2/related
   384  	path := fmt.Sprintf("/changes/%s/revisions/%s/related",
   385  		gerritChangeIDForRouting(req.Number, req.Project), req.RevisionId)
   386  	out := struct {
   387  		Changes []relatedChangeAndCommitInfo `json:"changes"`
   388  	}{}
   389  	if _, err := c.call(ctx, "GET", path, nil, nil, &out, opts); err != nil {
   390  		return nil, errors.Annotate(err, "related changes").Err()
   391  	}
   392  	changes := make([]*gerritpb.GetRelatedChangesResponse_ChangeAndCommit, len(out.Changes))
   393  	for i, c := range out.Changes {
   394  		changes[i] = c.ToProto()
   395  	}
   396  	return &gerritpb.GetRelatedChangesResponse{Changes: changes}, nil
   397  }
   398  
   399  func (c *client) GetPureRevert(ctx context.Context, req *gerritpb.GetPureRevertRequest, opts ...grpc.CallOption) (*gerritpb.PureRevertInfo, error) {
   400  	var resp gerritpb.PureRevertInfo
   401  	path := fmt.Sprintf("/changes/%s/pure_revert", gerritChangeIDForRouting(req.Number, req.Project))
   402  	if _, err := c.call(ctx, "GET", path, url.Values{}, nil, &resp, opts); err != nil {
   403  		return nil, errors.Annotate(err, "pure revert").Err()
   404  	}
   405  	return &resp, nil
   406  }
   407  
   408  func (c *client) ListFileOwners(ctx context.Context, req *gerritpb.ListFileOwnersRequest, opts ...grpc.CallOption) (*gerritpb.ListOwnersResponse, error) {
   409  	resp := struct {
   410  		CodeOwners []ownerInfo `json:"code_owners"`
   411  	}{}
   412  	params := url.Values{}
   413  	if req.Options.Details {
   414  		params.Add("o", "DETAILS")
   415  	}
   416  	if req.Options.AllEmails {
   417  		params.Add("o", "ALL_EMAILS")
   418  	}
   419  
   420  	path := fmt.Sprintf("/projects/%s/branches/%s/code_owners/%s", url.PathEscape(req.Project), url.PathEscape(req.Ref), url.PathEscape(req.Path))
   421  	if _, err := c.call(ctx, "GET", path, params, nil, &resp, opts); err != nil {
   422  		return nil, errors.Annotate(err, "list file owners").Err()
   423  	}
   424  	owners := make([]*gerritpb.OwnerInfo, len(resp.CodeOwners))
   425  	for i, owner := range resp.CodeOwners {
   426  		owners[i] = &gerritpb.OwnerInfo{Account: owner.Account.ToProto()}
   427  	}
   428  	return &gerritpb.ListOwnersResponse{
   429  		Owners: owners,
   430  	}, nil
   431  }
   432  
   433  func (c *client) ListProjects(ctx context.Context, req *gerritpb.ListProjectsRequest, opts ...grpc.CallOption) (*gerritpb.ListProjectsResponse, error) {
   434  	resp := map[string]*projectInfo{}
   435  	params := url.Values{}
   436  	for _, ref := range req.Refs {
   437  		params.Add("b", ref)
   438  	}
   439  	if _, err := c.call(ctx, "GET", "/projects/", params, nil, &resp, opts); err != nil {
   440  		return nil, errors.Annotate(err, "list projects").Err()
   441  	}
   442  	projectProtos := make(map[string]*gerritpb.ProjectInfo, len(resp))
   443  	for id, p := range resp {
   444  		projectInfo, err := p.ToProto()
   445  		if err != nil {
   446  			return nil, errors.Annotate(err, "decoding response").Err()
   447  		}
   448  		projectProtos[id] = projectInfo
   449  	}
   450  	return &gerritpb.ListProjectsResponse{
   451  		Projects: projectProtos,
   452  	}, nil
   453  }
   454  
   455  func (c *client) GetRefInfo(ctx context.Context, req *gerritpb.RefInfoRequest, opts ...grpc.CallOption) (*gerritpb.RefInfo, error) {
   456  	var resp gerritpb.RefInfo
   457  	path := fmt.Sprintf("/projects/%s/branches/%s", url.PathEscape(req.Project), url.PathEscape(req.Ref))
   458  	if _, err := c.call(ctx, "GET", path, url.Values{}, nil, &resp, opts); err != nil {
   459  		return nil, errors.Annotate(err, "get branch info").Err()
   460  	}
   461  	resp.Ref = branchToRef(resp.Ref)
   462  	return &resp, nil
   463  }
   464  
   465  func (c *client) GetMetaDiff(ctx context.Context, req *gerritpb.GetMetaDiffRequest, opts ...grpc.CallOption) (*gerritpb.MetaDiff, error) {
   466  	if err := checkArgs(opts, req); err != nil {
   467  		return nil, err
   468  	}
   469  	changeID := gerritChangeIDForRouting(req.GetNumber(), req.GetProject())
   470  	path := fmt.Sprintf("/changes/%s/meta_diff", changeID)
   471  	params := url.Values{}
   472  	if req.GetOld() != "" {
   473  		params.Add("old", req.GetOld())
   474  	}
   475  	if req.GetMeta() != "" {
   476  		params.Add("meta", req.GetMeta())
   477  	}
   478  	for _, o := range req.Options {
   479  		params.Add("o", o.String())
   480  	}
   481  
   482  	var resp metaDiff
   483  	if _, err := c.call(ctx, "GET", path, params, nil, &resp, opts); err != nil {
   484  		return nil, err
   485  	}
   486  	return resp.ToProto()
   487  }
   488  
   489  // call executes a request to Gerrit REST API with JSON input/output.
   490  // If data is nil, request will be made without a body.
   491  //
   492  // call returns HTTP status code and gRPC error.
   493  // If error happens before HTTP status code was determined, HTTP status code
   494  // will be -1.
   495  func (c *client) call(
   496  	ctx context.Context,
   497  	method, urlPath string,
   498  	params url.Values,
   499  	data, dest any,
   500  	opts []grpc.CallOption,
   501  	expectedHTTPCodes ...int,
   502  ) (int, error) {
   503  	headers := make(map[string]string)
   504  
   505  	var rawData []byte
   506  	if data != nil {
   507  		if method == "GET" {
   508  			// This error prevents a more cryptic HTTP error that would otherwise be
   509  			// returned by Gerrit if you try to call it with a GET request and a request
   510  			// body.
   511  			panic("data cannot be provided for a GET request")
   512  		}
   513  		var err error
   514  		rawData, err = json.Marshal(data)
   515  		if err != nil {
   516  			return -1, status.Errorf(codes.Internal, "failed to serialize request message: %s", err)
   517  		}
   518  		headers["Content-Type"] = contentTypeJSON
   519  	}
   520  
   521  	ret, body, err := c.callRaw(ctx, method, urlPath, params, headers, rawData, opts, expectedHTTPCodes...)
   522  	body = bytes.TrimPrefix(body, jsonPrefix)
   523  	if err == nil && dest != nil {
   524  		if err = json.Unmarshal(body, dest); err != nil {
   525  			// Special case for hosts which respond with a redirect when ACLs are
   526  			// misconfigured.
   527  			if bytes.Contains(body, []byte("<html")) && bytes.Contains(body, []byte("Single Sign On")) {
   528  				return ret, status.Errorf(codes.PermissionDenied, "redirected to Single Sign On")
   529  			}
   530  			logging.Errorf(ctx, "failed to deserialize response %s; body:\n\n%s", err, string(body))
   531  			return ret, status.Errorf(codes.Internal, "failed to deserialize response: %s", err)
   532  		}
   533  	}
   534  	return ret, err
   535  }
   536  
   537  // callRaw executes a request to Gerrit REST API with raw bytes input/output.
   538  //
   539  // callRaw returns HTTP status code and gRPC error.
   540  // If error happens before HTTP status code was determined, HTTP status code
   541  // will be -1.
   542  func (c *client) callRaw(
   543  	ctx context.Context,
   544  	method, urlPath string,
   545  	params url.Values,
   546  	headers map[string]string,
   547  	data []byte,
   548  	opts []grpc.CallOption,
   549  	expectedHTTPCodes ...int,
   550  ) (int, []byte, error) {
   551  	url := c.buildURL(urlPath, params, opts)
   552  	var requestBody io.Reader
   553  	if data != nil {
   554  		requestBody = bytes.NewBuffer(data)
   555  	}
   556  	req, err := http.NewRequest(method, url, requestBody)
   557  	if err != nil {
   558  		return 0, []byte{}, status.Errorf(codes.Internal, "failed to create an HTTP request: %s", err)
   559  	}
   560  
   561  	for k, v := range headers {
   562  		req.Header.Set(k, v)
   563  	}
   564  
   565  	res, err := ctxhttp.Do(ctx, c.hClient, req)
   566  	switch {
   567  	case err == context.DeadlineExceeded:
   568  		return -1, []byte{}, status.Errorf(codes.DeadlineExceeded, "deadline exceeded")
   569  	case err == context.Canceled:
   570  		// TODO(crbug/1289476): Remove this HACK that tries to identify Gerrit
   571  		// timeout after fixing the bug in the clock package.
   572  		if deadline, ok := ctx.Deadline(); ok && clock.Now(ctx).After(deadline) {
   573  			return -1, []byte{}, status.Errorf(codes.DeadlineExceeded, "deadline exceeded")
   574  		}
   575  		return -1, []byte{}, status.Errorf(codes.Canceled, "context is cancelled")
   576  	case err != nil:
   577  		return -1, []byte{}, status.Errorf(codes.Internal, "failed to execute %s HTTP request: %s", method, err)
   578  	}
   579  	defer res.Body.Close()
   580  
   581  	body, err := io.ReadAll(res.Body)
   582  	if err != nil {
   583  		return res.StatusCode, []byte{}, status.Errorf(codes.Internal, "failed to read response: %s", err)
   584  	}
   585  	logging.Debugf(ctx, "HTTP %d %d <= %s %s", res.StatusCode, len(body), method, url)
   586  
   587  	expectedHTTPCodes = append(expectedHTTPCodes, http.StatusOK)
   588  	for _, s := range expectedHTTPCodes {
   589  		if res.StatusCode == s {
   590  			return res.StatusCode, body, nil
   591  		}
   592  	}
   593  
   594  	switch res.StatusCode {
   595  	case http.StatusTooManyRequests:
   596  		logging.Debugf(ctx, "Gerrit quota error.\nResponse headers: %v\nResponse body: %s", res.Header, body)
   597  		return res.StatusCode, body, status.Errorf(codes.ResourceExhausted, "insufficient Gerrit quota")
   598  
   599  	case http.StatusForbidden:
   600  		logging.Debugf(ctx, "Gerrit permission denied:\nResponse headers: %v\nResponse body: %s", res.Header, body)
   601  		return res.StatusCode, body, status.Errorf(codes.PermissionDenied, "permission denied")
   602  
   603  	case http.StatusNotFound:
   604  		return res.StatusCode, body, status.Errorf(codes.NotFound, "not found")
   605  
   606  	// Both codes are mapped to codes.FailedPrecondition so that apps using
   607  	// this Gerrit client wouldn't be able to distinguish them by the grpc code.
   608  	// However,
   609  	// - http.StatusConflict(409) is returned by mutation Gerrit APIs only, but
   610  	// - http.StatusPreconditionFailed(412) is returned by the fetch APIs only.
   611  	//
   612  	// Hence, apps shouldn't have to distinguish them from
   613  	// codes.FailedPrecondition. If Gerrit changes the rest APIs so that a
   614  	// single API can return both of them, then this can be revisited.
   615  	case http.StatusConflict, http.StatusPreconditionFailed:
   616  		// Gerrit returns error message in the response body.
   617  		return res.StatusCode, body, status.Errorf(codes.FailedPrecondition, "%s", string(body))
   618  
   619  	case http.StatusBadRequest:
   620  		// Gerrit returns error message in the response body.
   621  		return res.StatusCode, body, status.Errorf(codes.InvalidArgument, "%s", string(body))
   622  
   623  	case http.StatusBadGateway:
   624  		return res.StatusCode, body, status.Errorf(codes.Unavailable, "bad gateway")
   625  
   626  	case http.StatusServiceUnavailable:
   627  		return res.StatusCode, body, status.Errorf(codes.Unavailable, "%s", string(body))
   628  
   629  	default:
   630  		logging.Errorf(ctx, "gerrit: unexpected HTTP %d response.\nResponse headers: %v\nResponse body: %s",
   631  			res.StatusCode,
   632  			res.Header, body)
   633  		return res.StatusCode, body, status.Errorf(codes.Internal, "unexpected HTTP %d from Gerrit", res.StatusCode)
   634  	}
   635  }
   636  
   637  func (c *client) buildURL(path string, params url.Values, opts []grpc.CallOption) string {
   638  	var url strings.Builder
   639  
   640  	host := c.host
   641  	for _, opt := range opts {
   642  		if g, ok := opt.(*gerritMirrorOption); ok {
   643  			host = g.altHost(host)
   644  		}
   645  	}
   646  
   647  	if c.testBaseURL != "" {
   648  		url.WriteString(c.testBaseURL)
   649  	} else {
   650  		url.WriteString("https://")
   651  		url.WriteString(host)
   652  		if c.auth {
   653  			url.WriteString("/a")
   654  		}
   655  	}
   656  
   657  	url.WriteString(path)
   658  	if len(params) > 0 {
   659  		url.WriteRune('?')
   660  		url.WriteString(params.Encode())
   661  	}
   662  	return url.String()
   663  }
   664  
   665  type validatable interface {
   666  	Validate() error
   667  }
   668  
   669  type gerritMirrorOption struct {
   670  	grpc.EmptyCallOption // to implement a grpc.CallOption
   671  	altHost              func(host string) string
   672  }
   673  
   674  // UseGerritMirror can be passed as grpc.CallOption to Gerrit client to select
   675  // an alternative Gerrit host to the one configured in the client "on the fly".
   676  func UseGerritMirror(altHost func(host string) string) grpc.CallOption {
   677  	if altHost == nil {
   678  		panic(fmt.Errorf("altHost must be non-nil"))
   679  	}
   680  	return &gerritMirrorOption{altHost: altHost}
   681  }
   682  
   683  func checkArgs(opts []grpc.CallOption, req validatable) error {
   684  	for _, opt := range opts {
   685  		if _, ok := opt.(*gerritMirrorOption); !ok {
   686  			return errors.New("gerrit.client supports only UseGerritMirror option")
   687  		}
   688  	}
   689  	if err := req.Validate(); err != nil {
   690  		return errors.Annotate(err, "request is invalid").Err()
   691  	}
   692  	return nil
   693  }
   694  
   695  func branchToRef(ref string) string {
   696  	if strings.HasPrefix(ref, "refs/") {
   697  		// Assume it's actually an explicit full ref.
   698  		// Also assume that ref "refs/heads/refs/XXX will be passed as is by Gerrit
   699  		// instead of "refs/XXX"
   700  		return ref
   701  	}
   702  	return "refs/heads/" + ref
   703  }
   704  
   705  func gerritChangeIDForRouting(number int64, project string) string {
   706  	if project != "" {
   707  		return fmt.Sprintf("%s~%d", url.PathEscape(project), number)
   708  	}
   709  	return strconv.Itoa(int(number))
   710  }
   711  
   712  func textInputHeaders() map[string]string {
   713  	return map[string]string{
   714  		"Content-Type": contentTypeText,
   715  		"Accept":       contentTypeJSON,
   716  	}
   717  }