blob: 8633afb0bdf7dc01625c0ecd149463756fb8cf03 [file] [log] [blame]
Ben Claytonf6660aa2021-07-20 20:25:38 +00001// Copyright 2021 The Tint 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
Ben Clayton53ddabe2022-04-12 16:13:31 +000015// Package gerrit provides helpers for obtaining information from Tint's gerrit instance
Ben Claytonf6660aa2021-07-20 20:25:38 +000016package gerrit
17
18import (
Ben Clayton53ddabe2022-04-12 16:13:31 +000019 "flag"
Ben Claytonf6660aa2021-07-20 20:25:38 +000020 "fmt"
21 "io/ioutil"
Ben Clayton53ddabe2022-04-12 16:13:31 +000022 "net/url"
Ben Claytonf6660aa2021-07-20 20:25:38 +000023 "os"
24 "regexp"
Ben Clayton53ddabe2022-04-12 16:13:31 +000025 "strconv"
Ben Claytonf6660aa2021-07-20 20:25:38 +000026 "strings"
27
28 "github.com/andygrunwald/go-gerrit"
29)
30
Ben Clayton53ddabe2022-04-12 16:13:31 +000031// Gerrit is the interface to gerrit
32type Gerrit struct {
Ben Claytonf6660aa2021-07-20 20:25:38 +000033 client *gerrit.Client
34 authenticated bool
35}
36
Ben Clayton53ddabe2022-04-12 16:13:31 +000037// Credentials holds the user name and password used to access Gerrit.
38type Credentials struct {
Ben Claytonf6660aa2021-07-20 20:25:38 +000039 Username string
40 Password string
41}
42
Ben Clayton53ddabe2022-04-12 16:13:31 +000043// Patchset refers to a single gerrit patchset
44type Patchset struct {
45 // Gerrit host
46 Host string
47 // Gerrit project
48 Project string
49 // Change ID
50 Change int
51 // Patchset ID
52 Patchset int
Ben Clayton5209a812021-07-30 16:20:46 +000053}
54
Ben Clayton53ddabe2022-04-12 16:13:31 +000055// ChangeInfo is an alias to gerrit.ChangeInfo
56type ChangeInfo = gerrit.ChangeInfo
57
58// LatestPatchest returns the latest Patchset from the ChangeInfo
59func LatestPatchest(change *ChangeInfo) Patchset {
60 u, _ := url.Parse(change.URL)
61 ps := Patchset{
62 Host: u.Host,
63 Project: change.Project,
64 Change: change.Number,
65 Patchset: change.Revisions[change.CurrentRevision].Number,
66 }
67 return ps
68}
69
70// RegisterFlags registers the command line flags to populate p
71func (p *Patchset) RegisterFlags(defaultHost, defaultProject string) {
72 flag.StringVar(&p.Host, "host", defaultHost, "gerrit host")
73 flag.StringVar(&p.Project, "project", defaultProject, "gerrit project")
74 flag.IntVar(&p.Change, "cl", 0, "gerrit change id")
75 flag.IntVar(&p.Patchset, "ps", 0, "gerrit patchset id")
76}
77
Ben Clayton526087b2022-04-29 15:15:43 +000078// RefsChanges returns the gerrit 'refs/changes/X/Y/Z' string for the patchset
79func (p Patchset) RefsChanges() string {
80 // https://gerrit-review.googlesource.com/Documentation/intro-user.html
81 // A change ref has the format refs/changes/X/Y/Z where X is the last two
82 // digits of the change number, Y is the entire change number, and Z is the
83 // patch set. For example, if the change number is 263270, the ref would be
84 // refs/changes/70/263270/2 for the second patch set.
85 shortChange := fmt.Sprintf("%.2v", p.Change)
86 shortChange = shortChange[len(shortChange)-2:]
87 return fmt.Sprintf("refs/changes/%v/%v/%v", shortChange, p.Change, p.Patchset)
88}
89
Ben Clayton53ddabe2022-04-12 16:13:31 +000090// LoadCredentials attempts to load the gerrit credentials for the given gerrit
91// URL from the git cookies file. Returns an empty Credentials on failure.
92func LoadCredentials(url string) Credentials {
93 cookiesFile := os.Getenv("HOME") + "/.gitcookies"
94 if cookies, err := ioutil.ReadFile(cookiesFile); err == nil {
Ben Clayton1c3f88e2022-04-26 14:53:42 +000095 url := strings.TrimSuffix(strings.TrimPrefix(url, "https://"), "/")
96 re := regexp.MustCompile(url + `/?\s+(?:FALSE|TRUE)[\s/]+(?:FALSE|TRUE)\s+[0-9]+\s+.\s+(.*)=(.*)`)
Ben Clayton53ddabe2022-04-12 16:13:31 +000097 match := re.FindStringSubmatch(string(cookies))
98 if len(match) == 3 {
99 return Credentials{match[1], match[2]}
100 }
101 }
102 return Credentials{}
103}
104
105// New returns a new Gerrit instance. If credentials are not provided, then
106// New() will automatically attempt to load them from the gitcookies file.
107func New(url string, cred Credentials) (*Gerrit, error) {
108 client, err := gerrit.NewClient(url, nil)
Ben Claytonf6660aa2021-07-20 20:25:38 +0000109 if err != nil {
Ben Clayton5209a812021-07-30 16:20:46 +0000110 return nil, fmt.Errorf("couldn't create gerrit client: %w", err)
Ben Claytonf6660aa2021-07-20 20:25:38 +0000111 }
112
Ben Clayton53ddabe2022-04-12 16:13:31 +0000113 if cred.Username == "" {
114 cred = LoadCredentials(url)
Ben Claytonf6660aa2021-07-20 20:25:38 +0000115 }
116
Ben Clayton53ddabe2022-04-12 16:13:31 +0000117 if cred.Username != "" {
118 client.Authentication.SetBasicAuth(cred.Username, cred.Password)
Ben Claytonf6660aa2021-07-20 20:25:38 +0000119 }
120
Ben Clayton53ddabe2022-04-12 16:13:31 +0000121 return &Gerrit{client, cred.Username != ""}, nil
Ben Claytonf6660aa2021-07-20 20:25:38 +0000122}
123
Ben Clayton53ddabe2022-04-12 16:13:31 +0000124// QueryChanges returns the changes that match the given query strings.
125// See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators
126func (g *Gerrit) QueryChanges(querys ...string) (changes []gerrit.ChangeInfo, query string, err error) {
Ben Claytonf6660aa2021-07-20 20:25:38 +0000127 changes = []gerrit.ChangeInfo{}
Ben Clayton53ddabe2022-04-12 16:13:31 +0000128 query = strings.Join(querys, "+")
Ben Claytonf6660aa2021-07-20 20:25:38 +0000129 for {
130 batch, _, err := g.client.Changes.QueryChanges(&gerrit.QueryChangeOptions{
131 QueryOptions: gerrit.QueryOptions{Query: []string{query}},
132 Skip: len(changes),
133 })
134 if err != nil {
Ben Clayton53ddabe2022-04-12 16:13:31 +0000135 return nil, "", g.maybeWrapError(err)
Ben Claytonf6660aa2021-07-20 20:25:38 +0000136 }
137
138 changes = append(changes, *batch...)
139 if len(*batch) == 0 || !(*batch)[len(*batch)-1].MoreChanges {
140 break
141 }
142 }
143 return changes, query, nil
144}
Ben Clayton53ddabe2022-04-12 16:13:31 +0000145
146// Abandon abandons the change with the given changeID.
147func (g *Gerrit) Abandon(changeID string) error {
148 _, _, err := g.client.Changes.AbandonChange(changeID, &gerrit.AbandonInput{})
149 if err != nil {
150 return g.maybeWrapError(err)
151 }
152 return nil
153}
154
155// CreateChange creates a new change in the given project and branch, with the
156// given subject. If wip is true, then the change is constructed as
157// Work-In-Progress.
158func (g *Gerrit) CreateChange(project, branch, subject string, wip bool) (*ChangeInfo, error) {
159 change, _, err := g.client.Changes.CreateChange(&gerrit.ChangeInput{
160 Project: project,
161 Branch: branch,
162 Subject: subject,
163 WorkInProgress: wip,
164 })
165 if err != nil {
166 return nil, g.maybeWrapError(err)
167 }
168 return change, nil
169}
170
Austin Eng9d14b162022-06-10 16:25:43 +0000171// EditFiles replaces the content of the files in the given change. It deletes deletedFiles.
Ben Clayton53ddabe2022-04-12 16:13:31 +0000172// If newCommitMsg is not an empty string, then the commit message is replaced
173// with the string value.
Austin Eng9d14b162022-06-10 16:25:43 +0000174func (g *Gerrit) EditFiles(changeID, newCommitMsg string, files map[string]string, deletedFiles []string) (Patchset, error) {
Ben Clayton53ddabe2022-04-12 16:13:31 +0000175 if newCommitMsg != "" {
176 resp, err := g.client.Changes.ChangeCommitMessageInChangeEdit(changeID, &gerrit.ChangeEditMessageInput{
177 Message: newCommitMsg,
178 })
179 if err != nil && resp.StatusCode != 409 { // 409 no changes were made
180 return Patchset{}, g.maybeWrapError(err)
181 }
182 }
183 for path, content := range files {
184 resp, err := g.client.Changes.ChangeFileContentInChangeEdit(changeID, path, content)
185 if err != nil && resp.StatusCode != 409 { // 409 no changes were made
186 return Patchset{}, g.maybeWrapError(err)
187 }
188 }
Austin Eng9d14b162022-06-10 16:25:43 +0000189 for _, path := range deletedFiles {
190 resp, err := g.client.Changes.DeleteFileInChangeEdit(changeID, path)
191 if err != nil && resp.StatusCode != 409 { // 409 no changes were made
192 return Patchset{}, g.maybeWrapError(err)
193 }
194 }
Ben Clayton53ddabe2022-04-12 16:13:31 +0000195
196 resp, err := g.client.Changes.PublishChangeEdit(changeID, "NONE")
197 if err != nil && resp.StatusCode != 409 { // 409 no changes were made
198 return Patchset{}, g.maybeWrapError(err)
199 }
200
201 return g.LatestPatchest(changeID)
202}
203
204// LatestPatchest returns the latest patchset for the change.
205func (g *Gerrit) LatestPatchest(changeID string) (Patchset, error) {
206 change, _, err := g.client.Changes.GetChange(changeID, &gerrit.ChangeOptions{
207 AdditionalFields: []string{"CURRENT_REVISION"},
208 })
209 if err != nil {
210 return Patchset{}, g.maybeWrapError(err)
211 }
212 ps := Patchset{
213 Host: g.client.BaseURL().Host,
214 Project: change.Project,
215 Change: change.Number,
216 Patchset: change.Revisions[change.CurrentRevision].Number,
217 }
218 return ps, nil
219}
220
Ben Clayton526087b2022-04-29 15:15:43 +0000221// CommentSide is an enumerator for specifying which side code-comments should
222// be shown.
223type CommentSide int
224
225const (
226 // Left is used to specifiy that code comments should appear on the parent
227 // change
228 Left CommentSide = iota
229 // Right is used to specifiy that code comments should appear on the new
230 // change
231 Right
232)
233
234// FileComment describes a single comment on a file
235type FileComment struct {
236 Path string // The file path
237 Side CommentSide // Which side the comment should appear
238 Line int // The 1-based line number for the comment
239 Message string // The comment message
240}
241
Ben Clayton53ddabe2022-04-12 16:13:31 +0000242// Comment posts a review comment on the given patchset.
Ben Clayton526087b2022-04-29 15:15:43 +0000243// If comments is an optional list of file-comments to include in the comment.
244func (g *Gerrit) Comment(ps Patchset, msg string, comments []FileComment) error {
245 input := &gerrit.ReviewInput{
246 Message: msg,
247 }
248 if len(comments) > 0 {
249 input.Comments = map[string][]gerrit.CommentInput{}
250 for _, c := range comments {
251 ci := gerrit.CommentInput{
252 Line: c.Line,
253 // Updated: &gerrit.Timestamp{Time: time.Now()},
254 Message: c.Message,
255 }
256 if c.Side == Left {
257 ci.Side = "PARENT"
258 } else {
259 ci.Side = "REVISION"
260 }
261 input.Comments[c.Path] = append(input.Comments[c.Path], ci)
262 }
263 }
264 _, _, err := g.client.Changes.SetReview(strconv.Itoa(ps.Change), strconv.Itoa(ps.Patchset), input)
Ben Clayton53ddabe2022-04-12 16:13:31 +0000265 if err != nil {
266 return g.maybeWrapError(err)
267 }
268 return nil
269}
Ben Clayton526087b2022-04-29 15:15:43 +0000270
271// SetReadyForReview marks the change as ready for review.
272func (g *Gerrit) SetReadyForReview(changeID, message string) error {
273 resp, err := g.client.Changes.SetReadyForReview(changeID, &gerrit.ReadyForReviewInput{
274 Message: message,
275 })
276 if err != nil && resp.StatusCode != 409 { // 409: already ready
277 return g.maybeWrapError(err)
278 }
279 return nil
280}
281
Ben Clayton53ddabe2022-04-12 16:13:31 +0000282func (g *Gerrit) maybeWrapError(err error) error {
283 if err != nil && !g.authenticated {
284 return fmt.Errorf(`query failed, possibly because of authentication.
James Priceb7179aa2023-03-23 19:03:29 +0000285See https://dawn.googlesource.com/new-password for obtaining a username
Ben Clayton53ddabe2022-04-12 16:13:31 +0000286and password which can be provided with --gerrit-user and --gerrit-pass.
287Note: This tool will scan ~/.gitcookies for credentials.
288%w`, err)
289 }
290 return err
291}