// Copyright 2015 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package gerrit contains code to interact with Gerrit servers. // // This package doesn't intend to provide complete coverage of the Gerrit API, // but only a small subset for the current needs of the Go project infrastructure. // Its API is not subject to the Go 1 compatibility promise and may change at any time. // For general-purpose Gerrit API clients, see https://pkg.go.dev/search?q=gerrit. package gerrit import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) // Client is a Gerrit client. type Client struct { url string // URL prefix, e.g. "https://go-review.googlesource.com" (without trailing slash) auth Auth // HTTPClient optionally specifies an HTTP client to use // instead of http.DefaultClient. HTTPClient *http.Client } // NewClient returns a new Gerrit client with the given URL prefix // and authentication mode. // The url should be just the scheme and hostname. For example, "https://go-review.googlesource.com". // If auth is nil, a default is used, or requests are made unauthenticated. func NewClient(url string, auth Auth) *Client { if auth == nil { // TODO(bradfitz): use GitCookies auth, once that exists auth = NoAuth } return &Client{ url: strings.TrimSuffix(url, "/"), auth: auth, } } func (c *Client) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return http.DefaultClient } // ErrResourceNotExist is returned when the requested resource doesn't exist. // It is only for use with errors.Is. var ErrResourceNotExist = errors.New("gerrit: requested resource does not exist") // ErrNotModified is returned when a modification didn't result in any change. // It is only for use with errors.Is. Not all APIs return this error; check the documentation. var ErrNotModified = errors.New("gerrit: requested modification resulted in no change") // HTTPError is the error type returned when a Gerrit API call does not return // the expected status. type HTTPError struct { Res *http.Response // non-nil Body []byte // 4KB prefix BodyErr error // any error reading Body } func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP status %s on request to %s; %s", e.Res.Status, e.Res.Request.URL, e.Body) } func (e *HTTPError) Is(target error) bool { switch target { case ErrResourceNotExist: return e.Res.StatusCode == http.StatusNotFound case ErrNotModified: // As of writing, this error text is the only way to distinguish different Conflict errors. See // https://cs.opensource.google/gerrit/gerrit/gerrit/+/master:java/com/google/gerrit/server/restapi/change/ChangeEdits.java;l=346;drc=d338da307a518f7f28b94310c1c083c997ca3c6a // https://cs.opensource.google/gerrit/gerrit/gerrit/+/master:java/com/google/gerrit/server/edit/ChangeEditModifier.java;l=453;drc=3bc970bb3e689d1d340382c3f5e5285d44f91dbf return e.Res.StatusCode == http.StatusConflict && bytes.Contains(e.Body, []byte("no changes were made")) default: return false } } // doArg is an optional argument for the Client.do method. type doArg interface { isDoArg() } type wantResStatus int func (wantResStatus) isDoArg() {} // reqBodyJSON sets the request body to a JSON encoding of v, // and the request's Content-Type header to "application/json". type reqBodyJSON struct{ v interface{} } func (reqBodyJSON) isDoArg() {} // reqBodyRaw sets the request body to r, // and the request's Content-Type header to "application/octet-stream". type reqBodyRaw struct{ r io.Reader } func (reqBodyRaw) isDoArg() {} type urlValues url.Values func (urlValues) isDoArg() {} // respBodyRaw returns the body of the response. If set, dst is ignored. type respBodyRaw struct{ rc *io.ReadCloser } func (respBodyRaw) isDoArg() {} func (c *Client) do(ctx context.Context, dst interface{}, method, path string, opts ...doArg) error { var arg url.Values var requestBody io.Reader var contentType string var wantStatus = http.StatusOK var responseBody *io.ReadCloser for _, opt := range opts { switch opt := opt.(type) { case wantResStatus: wantStatus = int(opt) case reqBodyJSON: b, err := json.MarshalIndent(opt.v, "", " ") if err != nil { return err } requestBody = bytes.NewReader(b) contentType = "application/json" case reqBodyRaw: requestBody = opt.r contentType = "application/octet-stream" case urlValues: arg = url.Values(opt) case respBodyRaw: responseBody = opt.rc default: panic(fmt.Sprintf("internal error; unsupported type %T", opt)) } } // slashA is either "/a" (for authenticated requests) or "" for unauthenticated. // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#authentication slashA := "/a" if _, ok := c.auth.(noAuth); ok { slashA = "" } u := c.url + slashA + path if arg != nil { u += "?" + arg.Encode() } req, err := http.NewRequestWithContext(ctx, method, u, requestBody) if err != nil { return err } if contentType != "" { req.Header.Set("Content-Type", contentType) } if err := c.auth.setAuth(c, req); err != nil { return fmt.Errorf("setting Gerrit auth: %v", err) } res, err := c.httpClient().Do(req) if err != nil { return err } defer func() { if responseBody != nil && *responseBody != nil { // We've handed off the body to the user. return } res.Body.Close() }() if res.StatusCode != wantStatus { body, err := io.ReadAll(io.LimitReader(res.Body, 4<<10)) return &HTTPError{res, body, err} } if responseBody != nil { *responseBody = res.Body return nil } if dst == nil { // Drain the response body, return an error if it's anything but empty. body, err := io.ReadAll(io.LimitReader(res.Body, 4<<10)) if err != nil || len(body) != 0 { return &HTTPError{res, body, err} } return nil } // The JSON response begins with an XSRF-defeating header // like ")]}\n". Read that and skip it. br := bufio.NewReader(res.Body) if _, err := br.ReadSlice('\n'); err != nil { return err } return json.NewDecoder(br).Decode(dst) } // Possible values for the ChangeInfo Status field. const ( ChangeStatusNew = "NEW" ChangeStatusAbandoned = "ABANDONED" ChangeStatusMerged = "MERGED" ) // ChangeInfo is a Gerrit data structure. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info type ChangeInfo struct { // The ID of the change. Subject to a 'GerritBackendFeature__return_new_change_info_id' experiment, // the format is either "'~<_number>'" (new format), // or "'~~'" (old format). // 'project', '_number', and 'branch' are URL encoded. // For 'branch' the refs/heads/ prefix is omitted. // The callers must not rely on the format. ID string `json:"id"` // ChangeNumber is a change number like "4247". ChangeNumber int `json:"_number"` // ChangeID is the Change-Id footer value like "I8473b95934b5732ac55d26311a706c9c2bde9940". // Note that some of the functions in this package take a changeID parameter that is a {change-id}, // which is a distinct concept from a Change-Id footer. (See the documentation links for details, // including https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id). ChangeID string `json:"change_id"` Project string `json:"project"` // Branch is the name of the target branch. // The refs/heads/ prefix is omitted. Branch string `json:"branch"` Topic string `json:"topic"` Assignee *AccountInfo `json:"assignee"` Hashtags []string `json:"hashtags"` // Subject is the subject of the change // (the header line of the commit message). Subject string `json:"subject"` // Status is the status of the change (NEW, SUBMITTED, MERGED, // ABANDONED, DRAFT). Status string `json:"status"` Created TimeStamp `json:"created"` Updated TimeStamp `json:"updated"` Submitted TimeStamp `json:"submitted"` Submitter *AccountInfo `json:"submitter"` SubmitType string `json:"submit_type"` // Mergeable indicates whether the change can be merged. // It is not set for already-merged changes, // nor if the change is untested, nor if the // SKIP_MERGEABLE option has been set. Mergeable bool `json:"mergeable"` // Submittable indicates whether the change can be submitted. // It is only set if requested, using the "SUBMITTABLE" option. Submittable bool `json:"submittable"` // Insertions and Deletions count inserted and deleted lines. Insertions int `json:"insertions"` Deletions int `json:"deletions"` // CurrentRevision is the commit ID of the current patch set // of this change. This is only set if the current revision // is requested or if all revisions are requested (fields // "CURRENT_REVISION" or "ALL_REVISIONS"). CurrentRevision string `json:"current_revision"` // Revisions maps a commit ID of the patch set to a // RevisionInfo entity. // // Only set if the current revision is requested (in which // case it will only contain a key for the current revision) // or if all revisions are requested. Revisions map[string]RevisionInfo `json:"revisions"` // Owner is the author of the change. // The details are only filled in if field "DETAILED_ACCOUNTS" is requested. Owner *AccountInfo `json:"owner"` // Messages are included if field "MESSAGES" is requested. Messages []ChangeMessageInfo `json:"messages"` // Labels maps label names to LabelInfo entries. Labels map[string]LabelInfo `json:"labels"` // ReviewerUpdates are included if field "REVIEWER_UPDATES" is requested. ReviewerUpdates []ReviewerUpdateInfo `json:"reviewer_updates"` // Reviewers maps reviewer state ("REVIEWER", "CC", "REMOVED") // to a list of accounts. // REVIEWER lists users with at least one non-zero vote on the change. // CC lists users added to the change who has not voted. // REMOVED lists users who were previously reviewers on the change // but who have been removed. // Reviewers is only included if "DETAILED_LABELS" is requested. Reviewers map[string][]*AccountInfo `json:"reviewers"` // WorkInProgress indicates that the change is marked as a work in progress. // (This means it is not yet ready for review, but it is still publicly visible.) WorkInProgress bool `json:"work_in_progress"` // HasReviewStarted indicates whether the change has ever been marked // ready for review in the past (not as a work in progress). HasReviewStarted bool `json:"has_review_started"` // RevertOf lists the numeric Change-Id of the change that this change reverts. RevertOf int `json:"revert_of"` // MoreChanges is set on the last change from QueryChanges if // the result set is truncated by an 'n' parameter. MoreChanges bool `json:"_more_changes"` } // ReviewerUpdateInfo is a Gerrit data structure. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info type ReviewerUpdateInfo struct { Updated TimeStamp `json:"updated"` UpdatedBy *AccountInfo `json:"updated_by"` Reviewer *AccountInfo `json:"reviewer"` State string // "REVIEWER", "CC", or "REMOVED" } // AccountInfo is a Gerrit data structure. It's used both for getting the details // for a single account, as well as for querying multiple accounts. // See https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info. type AccountInfo struct { NumericID int64 `json:"_account_id"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` Username string `json:"username,omitempty"` Tags []string `json:"tags,omitempty"` // MoreAccounts is set on the last account from QueryAccounts if // the result set is truncated by an 'n' parameter (or has more). MoreAccounts bool `json:"_more_accounts"` } func (ai *AccountInfo) Equal(v *AccountInfo) bool { if ai == nil || v == nil { return false } return ai.NumericID == v.NumericID } type ChangeMessageInfo struct { ID string `json:"id"` Author *AccountInfo `json:"author"` Time TimeStamp `json:"date"` Message string `json:"message"` Tag string `json:"tag,omitempty"` RevisionNumber int `json:"_revision_number"` } // The LabelInfo entity contains information about a label on a // change, always corresponding to the current patch set. // // There are two options that control the contents of LabelInfo: // LABELS and DETAILED_LABELS. // // For a quick summary of the state of labels, use LABELS. // // For detailed information about labels, including exact numeric // votes for all users and the allowed range of votes for the current // user, use DETAILED_LABELS. type LabelInfo struct { // Optional means the label may be set, but it’s neither // necessary for submission nor does it block submission if // set. Optional bool `json:"optional"` // Fields set by LABELS field option: Approved *AccountInfo `json:"approved"` // Fields set by DETAILED_LABELS option: All []ApprovalInfo `json:"all"` } type ApprovalInfo struct { AccountInfo Value int `json:"value"` Date TimeStamp `json:"date"` } // The RevisionInfo entity contains information about a patch set. Not // all fields are returned by default. Additional fields can be // obtained by adding o parameters as described at: // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes type RevisionInfo struct { Draft bool `json:"draft"` PatchSetNumber int `json:"_number"` Created TimeStamp `json:"created"` Uploader *AccountInfo `json:"uploader"` Ref string `json:"ref"` Fetch map[string]*FetchInfo `json:"fetch"` Commit *CommitInfo `json:"commit"` Files map[string]*FileInfo `json:"files"` CommitWithFooters string `json:"commit_with_footers"` Kind string `json:"kind"` // TODO: more } type CommitInfo struct { Author GitPersonInfo `json:"author"` Committer GitPersonInfo `json:"committer"` CommitID string `json:"commit"` Subject string `json:"subject"` Message string `json:"message"` Parents []CommitInfo `json:"parents"` } type GitPersonInfo struct { Name string `json:"name"` Email string `json:"Email"` Date TimeStamp `json:"date"` TZOffset int `json:"tz"` } func (gpi *GitPersonInfo) Equal(v *GitPersonInfo) bool { if gpi == nil { if gpi != v { return false } return true } return gpi.Name == v.Name && gpi.Email == v.Email && gpi.Date.Equal(v.Date) && gpi.TZOffset == v.TZOffset } // Possible values for the FileInfo Status field. const ( FileInfoAdded = "A" FileInfoDeleted = "D" FileInfoRenamed = "R" FileInfoCopied = "C" FileInfoRewritten = "W" ) type FileInfo struct { Status string `json:"status"` Binary bool `json:"binary"` OldPath string `json:"old_path"` LinesInserted int `json:"lines_inserted"` LinesDeleted int `json:"lines_deleted"` } type FetchInfo struct { URL string `json:"url"` Ref string `json:"ref"` Commands map[string]string `json:"commands"` } // QueryChangesOpt are options for QueryChanges. type QueryChangesOpt struct { // N is the number of results to return. // If 0, the 'n' parameter is not sent to Gerrit. N int // Start is the number of results to skip (useful in pagination). // To figure out if there are more results, the last ChangeInfo struct // in the last call to QueryChanges will have the field MoreAccounts=true. // If 0, the 'S' parameter is not sent to Gerrit. Start int // Fields are optional fields to also return. // Example strings include "ALL_REVISIONS", "LABELS", "MESSAGES". // For a complete list, see: // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info Fields []string } func condInt(n int) []string { if n != 0 { return []string{strconv.Itoa(n)} } return nil } // QueryChanges queries changes. The q parameter is a Gerrit search query. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes // For the query syntax, see https://gerrit-review.googlesource.com/Documentation/user-search.html#_search_operators func (c *Client) QueryChanges(ctx context.Context, q string, opts ...QueryChangesOpt) ([]*ChangeInfo, error) { var opt QueryChangesOpt switch len(opts) { case 0: case 1: opt = opts[0] default: return nil, errors.New("only 1 option struct supported") } var changes []*ChangeInfo err := c.do(ctx, &changes, "GET", "/changes/", urlValues{ "q": {q}, "n": condInt(opt.N), "o": opt.Fields, "S": condInt(opt.Start), }) return changes, err } // GetChange returns information about a single change. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-change func (c *Client) GetChange(ctx context.Context, changeID string, opts ...QueryChangesOpt) (*ChangeInfo, error) { var opt QueryChangesOpt switch len(opts) { case 0: case 1: opt = opts[0] default: return nil, errors.New("only 1 option struct supported") } var change ChangeInfo err := c.do(ctx, &change, "GET", "/changes/"+changeID, urlValues{ "n": condInt(opt.N), "o": opt.Fields, }) return &change, err } // GetChangeDetail retrieves a change with labels, detailed labels, detailed // accounts, and messages. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-change-detail func (c *Client) GetChangeDetail(ctx context.Context, changeID string, opts ...QueryChangesOpt) (*ChangeInfo, error) { var opt QueryChangesOpt switch len(opts) { case 0: case 1: opt = opts[0] default: return nil, errors.New("only 1 option struct supported") } var change ChangeInfo err := c.do(ctx, &change, "GET", "/changes/"+changeID+"/detail", urlValues{ "o": opt.Fields, }) if err != nil { return nil, err } return &change, nil } // ListChangeComments retrieves a map of published comments for the given change ID. // The map key is the file path (such as "maintner/git_test.go" or "/PATCHSET_LEVEL"). // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-change-comments. func (c *Client) ListChangeComments(ctx context.Context, changeID string) (map[string][]CommentInfo, error) { var m map[string][]CommentInfo if err := c.do(ctx, &m, "GET", "/changes/"+changeID+"/comments"); err != nil { return nil, err } return m, nil } // CommentInfo contains information about an inline comment. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info. type CommentInfo struct { PatchSet int `json:"patch_set,omitempty"` ID string `json:"id"` Path string `json:"path,omitempty"` Message string `json:"message,omitempty"` Updated TimeStamp `json:"updated"` Author *AccountInfo `json:"author,omitempty"` InReplyTo string `json:"in_reply_to,omitempty"` Unresolved *bool `json:"unresolved,omitempty"` Tag string `json:"tag,omitempty"` } // ListFiles retrieves a map of filenames to FileInfo's for the given change ID and revision. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-files func (c *Client) ListFiles(ctx context.Context, changeID, revision string) (map[string]*FileInfo, error) { var m map[string]*FileInfo if err := c.do(ctx, &m, "GET", "/changes/"+changeID+"/revisions/"+revision+"/files"); err != nil { return nil, err } return m, nil } // ReviewInput contains information for adding a review to a revision. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input type ReviewInput struct { Message string `json:"message,omitempty"` Labels map[string]int `json:"labels,omitempty"` Tag string `json:"tag,omitempty"` // Comments contains optional per-line comments to post. // The map key is a file path (such as "src/foo/bar.go"). Comments map[string][]CommentInput `json:"comments,omitempty"` // Reviewers optionally specifies new reviewers to add to the change. Reviewers []ReviewerInput `json:"reviewers,omitempty"` } // ReviewerInput contains information for adding a reviewer to a change. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-input type ReviewerInput struct { // Reviewer is the ID of the account to be added as reviewer. // See https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-id Reviewer string `json:"reviewer"` State string `json:"state,omitempty"` // REVIEWER or CC (default: REVIEWER) } // CommentInput contains information for creating an inline comment. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input type CommentInput struct { Line int `json:"line,omitempty"` Message string `json:"message"` InReplyTo string `json:"in_reply_to,omitempty"` Unresolved *bool `json:"unresolved,omitempty"` // TODO(haya14busa): more, as needed. } type reviewInfo struct { Labels map[string]int `json:"labels,omitempty"` } // SetReview leaves a message on a change and/or modifies labels. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review // The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id // The revision is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-id func (c *Client) SetReview(ctx context.Context, changeID, revision string, review ReviewInput) error { var res reviewInfo return c.do(ctx, &res, "POST", fmt.Sprintf("/changes/%s/revisions/%s/review", changeID, revision), reqBodyJSON{&review}) } // ReviewerInfo contains information about reviewers of a change. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-info type ReviewerInfo struct { AccountInfo Approvals map[string]string `json:"approvals"` } // ListReviewers returns all reviewers on a change. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-reviewers // The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id func (c *Client) ListReviewers(ctx context.Context, changeID string) ([]ReviewerInfo, error) { var res []ReviewerInfo if err := c.do(ctx, &res, "GET", fmt.Sprintf("/changes/%s/reviewers", changeID)); err != nil { return nil, err } return res, nil } // HashtagsInput is the request body used when modifying a CL's hashtags. // // See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#hashtags-input type HashtagsInput struct { Add []string `json:"add"` Remove []string `json:"remove"` } // SetHashtags modifies the hashtags for a CL, supporting both adding // and removing hashtags in one request. On success it returns the new // set of hashtags. // // See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#set-hashtags func (c *Client) SetHashtags(ctx context.Context, changeID string, hashtags HashtagsInput) ([]string, error) { var res []string err := c.do(ctx, &res, "POST", fmt.Sprintf("/changes/%s/hashtags", changeID), reqBodyJSON{&hashtags}) return res, err } // AddHashtags is a wrapper around SetHashtags that only supports adding tags. func (c *Client) AddHashtags(ctx context.Context, changeID string, tags ...string) ([]string, error) { return c.SetHashtags(ctx, changeID, HashtagsInput{Add: tags}) } // RemoveHashtags is a wrapper around SetHashtags that only supports removing tags. func (c *Client) RemoveHashtags(ctx context.Context, changeID string, tags ...string) ([]string, error) { return c.SetHashtags(ctx, changeID, HashtagsInput{Remove: tags}) } // GetHashtags returns a CL's current hashtags. // // See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#get-hashtags func (c *Client) GetHashtags(ctx context.Context, changeID string) ([]string, error) { var res []string err := c.do(ctx, &res, "GET", fmt.Sprintf("/changes/%s/hashtags", changeID)) return res, err } // AbandonChange abandons the given change. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#abandon-change // The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id // The input for the call is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#abandon-input func (c *Client) AbandonChange(ctx context.Context, changeID string, message ...string) error { var msg string if len(message) > 1 { panic("invalid use of multiple message inputs") } if len(message) == 1 { msg = message[0] } b := struct { Message string `json:"message,omitempty"` }{msg} var change ChangeInfo return c.do(ctx, &change, "POST", "/changes/"+changeID+"/abandon", reqBodyJSON{&b}) } // ProjectInput contains the options for creating a new project. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-input type ProjectInput struct { Parent string `json:"parent,omitempty"` Description string `json:"description,omitempty"` SubmitType string `json:"submit_type,omitempty"` CreateNewChangeForAllNotInTarget string `json:"create_new_change_for_all_not_in_target,omitempty"` // TODO(bradfitz): more, as needed. } // ProjectInfo is information about a Gerrit project. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info type ProjectInfo struct { ID string `json:"id"` Name string `json:"name"` Parent string `json:"parent"` CloneURL string `json:"clone_url"` Description string `json:"description"` State string `json:"state"` Branches map[string]string `json:"branches"` WebLinks []WebLinkInfo `json:"web_links,omitempty"` } // ListProjects returns the server's active projects. // // The returned slice is sorted by project ID and excludes the "All-Projects" and "All-Users" projects. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#list-projects. func (c *Client) ListProjects(ctx context.Context) ([]ProjectInfo, error) { var res map[string]ProjectInfo err := c.do(ctx, &res, "GET", "/projects/") if err != nil { return nil, err } var ret []ProjectInfo for name, pi := range res { if name == "All-Projects" || name == "All-Users" { continue } if pi.State != "ACTIVE" { continue } // https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info: // "name not set if returned in a map where the project name is used as map key" pi.Name = name ret = append(ret, pi) } sort.Slice(ret, func(i, j int) bool { return ret[i].ID < ret[j].ID }) return ret, nil } // CreateProject creates a new project. func (c *Client) CreateProject(ctx context.Context, name string, p ...ProjectInput) (ProjectInfo, error) { var pi ProjectInput if len(p) > 1 { panic("invalid use of multiple project inputs") } if len(p) == 1 { pi = p[0] } var res ProjectInfo err := c.do(ctx, &res, "PUT", fmt.Sprintf("/projects/%s", url.PathEscape(name)), reqBodyJSON{&pi}, wantResStatus(http.StatusCreated)) return res, err } // CreateChange creates a new change. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#create-change. func (c *Client) CreateChange(ctx context.Context, ci ChangeInput) (ChangeInfo, error) { var res ChangeInfo err := c.do(ctx, &res, "POST", "/changes/", reqBodyJSON{&ci}, wantResStatus(http.StatusCreated)) return res, err } // ChangeInput contains the options for creating a new change. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-input. type ChangeInput struct { Project string `json:"project"` Branch string `json:"branch"` Subject string `json:"subject"` } // ChangeFileContentInChangeEdit puts content of a file to a change edit. // If no change is made, an error that matches ErrNotModified is returned. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#put-edit-file. func (c *Client) ChangeFileContentInChangeEdit(ctx context.Context, changeID string, path string, content string) error { return c.do(ctx, nil, "PUT", "/changes/"+changeID+"/edit/"+url.PathEscape(path), reqBodyRaw{strings.NewReader(content)}, wantResStatus(http.StatusNoContent)) } // DeleteFileInChangeEdit deletes a file from a change edit. // If no change is made, an error that matches ErrNotModified is returned. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#delete-edit-file. func (c *Client) DeleteFileInChangeEdit(ctx context.Context, changeID string, path string) error { return c.do(ctx, nil, "DELETE", "/changes/"+changeID+"/edit/"+url.PathEscape(path), wantResStatus(http.StatusNoContent)) } // PublishChangeEdit promotes the change edit to a regular patch set. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#publish-edit. func (c *Client) PublishChangeEdit(ctx context.Context, changeID string) error { return c.do(ctx, nil, "POST", "/changes/"+changeID+"/edit:publish", wantResStatus(http.StatusNoContent)) } // GetProjectInfo returns info about a project. func (c *Client) GetProjectInfo(ctx context.Context, name string) (ProjectInfo, error) { var res ProjectInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s", url.PathEscape(name))) return res, err } // BranchInfo is information about a branch. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-info type BranchInfo struct { Ref string `json:"ref"` Revision string `json:"revision"` CanDelete bool `json:"can_delete"` } // GetProjectBranches returns the branches for the project name. The branches are stored in a map // keyed by reference. func (c *Client) GetProjectBranches(ctx context.Context, name string) (map[string]BranchInfo, error) { var res []BranchInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s/branches/", url.PathEscape(name))) if err != nil { return nil, err } m := map[string]BranchInfo{} for _, bi := range res { m[bi.Ref] = bi } return m, nil } // GetBranch gets a particular branch in project. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-branch. func (c *Client) GetBranch(ctx context.Context, project, branch string) (BranchInfo, error) { var res BranchInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s/branches/%s", url.PathEscape(project), branch)) return res, err } // GetFileContent gets a file's contents at a particular commit. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-content-from-commit. func (c *Client) GetFileContent(ctx context.Context, project, commit, path string) (io.ReadCloser, error) { var body io.ReadCloser err := c.do(ctx, nil, "GET", fmt.Sprintf("/projects/%s/commits/%s/files/%s/content", url.PathEscape(project), commit, url.PathEscape(path)), respBodyRaw{&body}) if err != nil { return nil, err } return readCloser{ Reader: base64.NewDecoder(base64.StdEncoding, body), Closer: body, }, nil } type readCloser struct { io.Reader io.Closer } // WebLinkInfo is information about a web link. // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info type WebLinkInfo struct { Name string `json:"name"` URL string `json:"url"` ImageURL string `json:"image_url"` } func (wli *WebLinkInfo) Equal(v *WebLinkInfo) bool { if wli == nil || v == nil { return false } return wli.Name == v.Name && wli.URL == v.URL && wli.ImageURL == v.ImageURL } // TagInfo is information about a tag. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info type TagInfo struct { Ref string `json:"ref"` Revision string `json:"revision"` Object string `json:"object,omitempty"` Message string `json:"message,omitempty"` Tagger *GitPersonInfo `json:"tagger,omitempty"` Created TimeStamp `json:"created,omitempty"` CanDelete bool `json:"can_delete"` WebLinks []WebLinkInfo `json:"web_links,omitempty"` } func (ti *TagInfo) Equal(v *TagInfo) bool { if ti == nil || v == nil { return false } if ti.Ref != v.Ref || ti.Revision != v.Revision || ti.Object != v.Object || ti.Message != v.Message || !ti.Created.Equal(v.Created) || ti.CanDelete != v.CanDelete { return false } if !ti.Tagger.Equal(v.Tagger) { return false } if len(ti.WebLinks) != len(v.WebLinks) { return false } for i := range ti.WebLinks { if !ti.WebLinks[i].Equal(&v.WebLinks[i]) { return false } } return true } // GetProjectTags returns the tags for the project name. The tags are stored in a map keyed by // reference. func (c *Client) GetProjectTags(ctx context.Context, name string) (map[string]TagInfo, error) { var res []TagInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s/tags/", url.PathEscape(name))) if err != nil { return nil, err } m := map[string]TagInfo{} for _, ti := range res { m[ti.Ref] = ti } return m, nil } // GetTag returns a particular tag on project. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-tag. func (c *Client) GetTag(ctx context.Context, project, tag string) (TagInfo, error) { var res TagInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s/tags/%s", url.PathEscape(project), url.PathEscape(tag))) return res, err } // TagInput contains information for creating a tag. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-input type TagInput struct { // Ref is optional, and when present must be equal to the URL parameter. Removed. Revision string `json:"revision,omitempty"` Message string `json:"message,omitempty"` } // CreateTag creates a tag on project. // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#create-tag. func (c *Client) CreateTag(ctx context.Context, project, tag string, input TagInput) (TagInfo, error) { var res TagInfo err := c.do(ctx, &res, "PUT", fmt.Sprintf("/projects/%s/tags/%s", project, url.PathEscape(tag)), reqBodyJSON{&input}, wantResStatus(http.StatusCreated)) return res, err } // GetAccountInfo gets the specified account's information from Gerrit. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-account // The accountID is https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-id // // Note that getting "self" is a good way to validate host access, since it only requires peeker // access to the host, not to any particular repository. func (c *Client) GetAccountInfo(ctx context.Context, accountID string) (AccountInfo, error) { var res AccountInfo err := c.do(ctx, &res, "GET", fmt.Sprintf("/accounts/%s", accountID)) return res, err } // QueryAccountsOpt are options for QueryAccounts. type QueryAccountsOpt struct { // N is the number of results to return. // If 0, the 'n' parameter is not sent to Gerrit. N int // Start is the number of results to skip (useful in pagination). // To figure out if there are more results, the last AccountInfo struct // in the last call to QueryAccounts will have the field MoreAccounts=true. // If 0, the 'S' parameter is not sent to Gerrit. Start int // Fields are optional fields to also return. // Example strings include "DETAILS", "ALL_EMAILS". // By default, only the account IDs are returned. // For a complete list, see: // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account Fields []string } // QueryAccounts queries accounts. The q parameter is a Gerrit search query. // For the API call and query syntax, see https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account func (c *Client) QueryAccounts(ctx context.Context, q string, opts ...QueryAccountsOpt) ([]*AccountInfo, error) { var opt QueryAccountsOpt switch len(opts) { case 0: case 1: opt = opts[0] default: return nil, errors.New("only 1 option struct supported") } var changes []*AccountInfo err := c.do(ctx, &changes, "GET", "/accounts/", urlValues{ "q": {q}, "n": condInt(opt.N), "o": opt.Fields, "S": condInt(opt.Start), }) return changes, err } type TimeStamp time.Time func (ts TimeStamp) Equal(v TimeStamp) bool { return ts.Time().Equal(v.Time()) } // Gerrit's timestamp layout is like time.RFC3339Nano, but with a space instead of the "T", // and without a timezone (it's always in UTC). const timeStampLayout = "2006-01-02 15:04:05.999999999" func (ts TimeStamp) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf(`"%s"`, ts.Time().UTC().Format(timeStampLayout))), nil } func (ts *TimeStamp) UnmarshalJSON(p []byte) error { if len(p) < 2 { return errors.New("timestamp too short") } if p[0] != '"' || p[len(p)-1] != '"' { return errors.New("not double-quoted") } s := strings.Trim(string(p), "\"") t, err := time.Parse(timeStampLayout, s) if err != nil { return err } *ts = TimeStamp(t) return nil } func (ts TimeStamp) Time() time.Time { return time.Time(ts) } // GroupInfo contains information about a group. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info. type GroupInfo struct { ID string `json:"id"` URL string `json:"url"` Name string `json:"name"` GroupID int64 `json:"group_id"` Options GroupOptionsInfo `json:"options"` Owner string `json:"owner"` OwnerID string `json:"owner_id"` } type GroupOptionsInfo struct { VisibleToAll bool `json:"visible_to_all"` } func (c *Client) GetGroups(ctx context.Context) (map[string]*GroupInfo, error) { res := make(map[string]*GroupInfo) err := c.do(ctx, &res, "GET", "/groups/") for k, gi := range res { if gi != nil && gi.Name == "" { gi.Name = k } } return res, err } func (c *Client) GetGroupMembers(ctx context.Context, groupID string) ([]AccountInfo, error) { var ais []AccountInfo err := c.do(ctx, &ais, "GET", "/groups/"+groupID+"/members") return ais, err } // SubmitChange submits the given change. // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-change // The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id func (c *Client) SubmitChange(ctx context.Context, changeID string) (ChangeInfo, error) { var change ChangeInfo err := c.do(ctx, &change, "POST", "/changes/"+changeID+"/submit") return change, err } // MergeableInfo contains information about the mergeability of a change. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info. type MergeableInfo struct { SubmitType string `json:"submit_type"` Strategy string `json:"strategy"` Mergeable bool `json:"mergeable"` CommitMerged bool `json:"commit_merged"` } // GetMergeable retrieves mergeability information for a change at a specific revision. func (c *Client) GetMergeable(ctx context.Context, changeID, revision string) (MergeableInfo, error) { var mergeable MergeableInfo err := c.do(ctx, &mergeable, "GET", "/changes/"+changeID+"/revisions/"+revision+"/mergeable") return mergeable, err } // ActionInfo contains information about actions a client can make to // manipulate a resource. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info. type ActionInfo struct { Method string `json:"method"` Label string `json:"label"` Title string `json:"title"` Enabled bool `json:"enabled"` } // GetRevisionActions retrieves revision actions. func (c *Client) GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*ActionInfo, error) { var actions map[string]*ActionInfo err := c.do(ctx, &actions, "GET", "/changes/"+changeID+"/revisions/"+revision+"/actions") return actions, err } // RelatedChangeAndCommitInfo contains information about a particular // change at a particular commit. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-change-and-commit-info. type RelatedChangeAndCommitInfo struct { Project string `json:"project"` ChangeID string `json:"change_id"` ChangeNumber int32 `json:"_change_number"` Commit CommitInfo `json:"commit"` Status string `json:"status"` } // RelatedChangesInfo contains information about a set of related changes. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-changes-info. type RelatedChangesInfo struct { Changes []RelatedChangeAndCommitInfo `json:"changes"` } // GetRelatedChanges retrieves information about a set of related changes. func (c *Client) GetRelatedChanges(ctx context.Context, changeID, revision string) (*RelatedChangesInfo, error) { var changes *RelatedChangesInfo err := c.do(ctx, &changes, "GET", "/changes/"+changeID+"/revisions/"+revision+"/related") return changes, err } // GetCommitsInRefs gets refs in which the specified commits were merged into. // // See https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commits-included-in. func (c *Client) GetCommitsInRefs(ctx context.Context, project string, commits, refs []string) (map[string][]string, error) { result := map[string][]string{} vals := url.Values{} vals["commit"] = commits vals["ref"] = refs err := c.do(ctx, &result, "GET", "/projects/"+url.PathEscape(project)+"/commits:in", urlValues(vals)) return result, err }