| // Copyright 2021 The Dawn & Tint Authors | |
| // | |
| // Redistribution and use in source and binary forms, with or without | |
| // modification, are permitted provided that the following conditions are met: | |
| // | |
| // 1. Redistributions of source code must retain the above copyright notice, this | |
| // list of conditions and the following disclaimer. | |
| // | |
| // 2. Redistributions in binary form must reproduce the above copyright notice, | |
| // this list of conditions and the following disclaimer in the documentation | |
| // and/or other materials provided with the distribution. | |
| // | |
| // 3. Neither the name of the copyright holder nor the names of its | |
| // contributors may be used to endorse or promote products derived from | |
| // this software without specific prior written permission. | |
| // | |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | |
| // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | |
| // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| // Package gerrit provides helpers for obtaining information from Tint's gerrit instance | |
| package gerrit | |
| import ( | |
| "context" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "net/url" | |
| "strconv" | |
| "strings" | |
| "dawn.googlesource.com/dawn/tools/src/container" | |
| "github.com/andygrunwald/go-gerrit" | |
| "go.chromium.org/luci/auth" | |
| ) | |
| // Gerrit is the interface to gerrit | |
| type Gerrit struct { | |
| client *gerrit.Client | |
| authenticated bool | |
| } | |
| // Patchset refers to a single gerrit patchset | |
| type Patchset struct { | |
| // Gerrit host | |
| Host string | |
| // Gerrit project | |
| Project string | |
| // Change ID | |
| Change int | |
| // Patchset ID | |
| Patchset int | |
| } | |
| // Aliases for gerrit.* types | |
| type ChangeInfo = gerrit.ChangeInfo | |
| type AccountInfo = gerrit.AccountInfo | |
| type RevisionInfo = gerrit.RevisionInfo | |
| type ChangeMessageInfo = gerrit.ChangeMessageInfo | |
| type Timestamp = gerrit.Timestamp | |
| // LatestPatchset returns the latest Patchset from the ChangeInfo | |
| func LatestPatchset(change *ChangeInfo) Patchset { | |
| u, _ := url.Parse(change.URL) | |
| ps := Patchset{ | |
| Host: u.Host, | |
| Project: change.Project, | |
| Change: change.Number, | |
| Patchset: change.Revisions[change.CurrentRevision].Number, | |
| } | |
| return ps | |
| } | |
| // RegisterFlags registers the command line flags to populate p | |
| func (p *Patchset) RegisterFlags(defaultHost, defaultProject string) { | |
| flag.StringVar(&p.Host, "host", defaultHost, "gerrit host") | |
| flag.StringVar(&p.Project, "project", defaultProject, "gerrit project") | |
| flag.IntVar(&p.Change, "cl", 0, "gerrit change id") | |
| flag.IntVar(&p.Patchset, "ps", 0, "gerrit patchset id") | |
| } | |
| // RefsChanges returns the gerrit 'refs/changes/X/Y/Z' string for the patchset | |
| func (p Patchset) RefsChanges() string { | |
| // https://gerrit-review.googlesource.com/Documentation/intro-user.html | |
| // A change ref has the format refs/changes/X/Y/Z where X is the last two | |
| // digits of the change number, Y is the entire change number, and Z is the | |
| // patch set. For example, if the change number is 263270, the ref would be | |
| // refs/changes/70/263270/2 for the second patch set. | |
| shortChange := fmt.Sprintf("%.2v", p.Change) | |
| shortChange = shortChange[len(shortChange)-2:] | |
| return fmt.Sprintf("refs/changes/%v/%v/%v", shortChange, p.Change, p.Patchset) | |
| } | |
| // New returns a new Gerrit instance. If credentials are not provided, then | |
| // New() will automatically attempt to load them from the gitcookies file. | |
| func New(ctx context.Context, opts auth.Options, url string) (*Gerrit, error) { | |
| http, err := auth.NewAuthenticator(ctx, auth.InteractiveLogin, opts).Client() | |
| if err != nil { | |
| return nil, fmt.Errorf("couldn't create gerrit client: %w", err) | |
| } | |
| client, err := gerrit.NewClient(ctx, url, http) | |
| if err != nil { | |
| return nil, fmt.Errorf("couldn't create gerrit client: %w", err) | |
| } | |
| return &Gerrit{client, true}, nil | |
| } | |
| // QueryExtraData holds extra data to query for with QueryChangesWith() | |
| type QueryExtraData struct { | |
| Labels bool | |
| Messages bool | |
| CurrentRevision bool | |
| DetailedAccounts bool | |
| Submittable bool | |
| } | |
| // QueryChanges returns the changes that match the given query strings. | |
| // See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators | |
| func (g *Gerrit) QueryChangesWith(ctx context.Context, extras QueryExtraData, queries ...string) (changes []gerrit.ChangeInfo, query string, err error) { | |
| changes = []gerrit.ChangeInfo{} | |
| query = strings.Join(queries, "+") | |
| changeOpts := gerrit.ChangeOptions{} | |
| if extras.Labels { | |
| changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "LABELS") | |
| } | |
| if extras.Messages { | |
| changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "MESSAGES") | |
| } | |
| if extras.CurrentRevision { | |
| changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "CURRENT_REVISION") | |
| } | |
| if extras.DetailedAccounts { | |
| changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "DETAILED_ACCOUNTS") | |
| } | |
| if extras.Submittable { | |
| changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "SUBMITTABLE") | |
| } | |
| for { | |
| batch, _, err := g.client.Changes.QueryChanges(ctx, &gerrit.QueryChangeOptions{ | |
| QueryOptions: gerrit.QueryOptions{Query: []string{query}}, | |
| Skip: len(changes), | |
| ChangeOptions: changeOpts, | |
| }) | |
| if err != nil { | |
| return nil, "", err | |
| } | |
| changes = append(changes, *batch...) | |
| if len(*batch) == 0 || !(*batch)[len(*batch)-1].MoreChanges { | |
| break | |
| } | |
| } | |
| return changes, query, nil | |
| } | |
| // QueryChanges returns the changes that match the given query strings. | |
| // See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators | |
| func (g *Gerrit) QueryChanges(ctx context.Context, queries ...string) (changes []gerrit.ChangeInfo, query string, err error) { | |
| return g.QueryChangesWith(ctx, QueryExtraData{}, queries...) | |
| } | |
| // ChangesSubmittedTogether returns the changes that want to be submitted together | |
| // See: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together | |
| func (g *Gerrit) ChangesSubmittedTogether(ctx context.Context, changeID string) (changes []gerrit.ChangeInfo, err error) { | |
| info, _, err := g.client.Changes.ChangesSubmittedTogether(ctx, changeID) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return *info, nil | |
| } | |
| func (g *Gerrit) AddLabel(ctx context.Context, changeID, revisionID, message, label string, value int) error { | |
| _, _, err := g.client.Changes.SetReview(ctx, changeID, revisionID, &gerrit.ReviewInput{ | |
| Message: message, | |
| Labels: map[string]int{label: value}, | |
| }) | |
| if err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| // Abandon abandons the change with the given changeID. | |
| func (g *Gerrit) Abandon(ctx context.Context, changeID string) error { | |
| _, _, err := g.client.Changes.AbandonChange(ctx, changeID, &gerrit.AbandonInput{}) | |
| if err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| // CreateChange creates a new change in the given project and branch, with the | |
| // given subject. If wip is true, then the change is constructed as | |
| // Work-In-Progress. | |
| func (g *Gerrit) CreateChange(ctx context.Context, project, branch, subject string, wip bool) (*ChangeInfo, error) { | |
| change, _, err := g.client.Changes.CreateChange(ctx, &gerrit.ChangeInput{ | |
| Project: project, | |
| Branch: branch, | |
| Subject: subject, | |
| WorkInProgress: wip, | |
| }) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if change.URL == "" { | |
| base := g.client.BaseURL() | |
| change.URL = fmt.Sprintf("%vc/%v/+/%v", base.String(), change.Project, change.Number) | |
| } | |
| return change, nil | |
| } | |
| // EditFiles replaces the content of the files in the given change. It deletes deletedFiles. | |
| // If newCommitMsg is not an empty string, then the commit message is replaced | |
| // with the string value. | |
| func (g *Gerrit) EditFiles(ctx context.Context, changeID, newCommitMsg string, files map[string]string, deletedFiles []string) (Patchset, error) { | |
| if newCommitMsg != "" { | |
| resp, err := g.client.Changes.ChangeCommitMessageInChangeEdit(ctx, changeID, &gerrit.ChangeEditMessageInput{ | |
| Message: newCommitMsg, | |
| }) | |
| if err != nil && resp.StatusCode != 409 { // 409 no changes were made | |
| return Patchset{}, err | |
| } | |
| } | |
| for path, content := range files { | |
| resp, err := g.client.Changes.ChangeFileContentInChangeEdit(ctx, changeID, path, content) | |
| if err != nil && resp.StatusCode != 409 { // 409 no changes were made | |
| return Patchset{}, err | |
| } | |
| } | |
| for _, path := range deletedFiles { | |
| resp, err := g.client.Changes.DeleteFileInChangeEdit(ctx, changeID, path) | |
| if err != nil && resp.StatusCode != 409 { // 409 no changes were made | |
| return Patchset{}, err | |
| } | |
| } | |
| resp, err := g.client.Changes.PublishChangeEdit(ctx, changeID, "NONE") | |
| if err != nil && resp.StatusCode != 409 { // 409 no changes were made | |
| return Patchset{}, err | |
| } | |
| return g.LatestPatchset(ctx, changeID) | |
| } | |
| // LatestPatchset returns the latest patchset for the change. | |
| func (g *Gerrit) LatestPatchset(ctx context.Context, changeID string) (Patchset, error) { | |
| change, _, err := g.client.Changes.GetChange(ctx, changeID, &gerrit.ChangeOptions{ | |
| AdditionalFields: []string{"CURRENT_REVISION"}, | |
| }) | |
| if err != nil { | |
| return Patchset{}, err | |
| } | |
| ps := Patchset{ | |
| Host: g.client.BaseURL().Host, | |
| Project: change.Project, | |
| Change: change.Number, | |
| Patchset: change.Revisions[change.CurrentRevision].Number, | |
| } | |
| return ps, nil | |
| } | |
| // AddHashtags adds the given hashtags to the change | |
| func (g *Gerrit) AddHashtags(ctx context.Context, changeID string, tags container.Set[string]) error { | |
| _, resp, err := g.client.Changes.SetHashtags(ctx, changeID, &gerrit.HashtagsInput{ | |
| Add: tags.List(), | |
| }) | |
| if err != nil && resp.StatusCode != 409 { // 409: already ready | |
| return err | |
| } | |
| return nil | |
| } | |
| // CommentSide is an enumerator for specifying which side code-comments should | |
| // be shown. | |
| type CommentSide int | |
| const ( | |
| // Left is used to specify that code comments should appear on the parent change | |
| Left CommentSide = iota | |
| // Right is used to specify that code comments should appear on the new change | |
| Right | |
| ) | |
| // FileComment describes a single comment on a file | |
| type FileComment struct { | |
| Path string // The file path | |
| Side CommentSide // Which side the comment should appear | |
| Line int // The 1-based line number for the comment | |
| Message string // The comment message | |
| } | |
| // Comment posts a review comment on the given patchset. | |
| // If comments is an optional list of file-comments to include in the comment. | |
| func (g *Gerrit) Comment(ctx context.Context, ps Patchset, msg string, comments []FileComment) error { | |
| input := &gerrit.ReviewInput{ | |
| Message: msg, | |
| } | |
| if len(comments) > 0 { | |
| input.Comments = map[string][]gerrit.CommentInput{} | |
| for _, c := range comments { | |
| ci := gerrit.CommentInput{ | |
| Line: c.Line, | |
| // Updated: &gerrit.Timestamp{Time: time.Now()}, | |
| Message: c.Message, | |
| } | |
| if c.Side == Left { | |
| ci.Side = "PARENT" | |
| } else { | |
| ci.Side = "REVISION" | |
| } | |
| input.Comments[c.Path] = append(input.Comments[c.Path], ci) | |
| } | |
| } | |
| _, _, err := g.client.Changes.SetReview(ctx, strconv.Itoa(ps.Change), strconv.Itoa(ps.Patchset), input) | |
| if err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| // SetReadyForReview marks the change as ready for review. | |
| func (g *Gerrit) SetReadyForReview(ctx context.Context, changeID, message, reviewer string) error { | |
| resp, err := g.client.Changes.SetReadyForReview(ctx, changeID, &gerrit.ReadyForReviewInput{ | |
| Message: message, | |
| }) | |
| if err != nil && resp.StatusCode != 409 { // 409: already ready | |
| return err | |
| } | |
| if reviewer != "" { | |
| log.Printf("Got reviewer %s", reviewer) | |
| _, resp, err = g.client.Changes.AddReviewer(ctx, changeID, &gerrit.ReviewerInput{ | |
| Reviewer: reviewer, | |
| }) | |
| if err != nil && resp.StatusCode != 409 { // 409: already ready | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| // ListAccessRights retrieves the access rights for the client for the specified | |
| // projects within the client's Gerrit instance. | |
| func (g *Gerrit) ListAccessRights( | |
| ctx context.Context, projects ...string) (*map[string]gerrit.ProjectAccessInfo, error) { | |
| listOptions := gerrit.ListAccessRightsOptions{ | |
| Project: projects, | |
| } | |
| accessInfo, _, err := g.client.Access.ListAccessRights(ctx, &listOptions) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return accessInfo, nil | |
| } |