Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 1 | // 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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 15 | // Package gerrit provides helpers for obtaining information from Tint's gerrit instance |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 16 | package gerrit |
| 17 | |
| 18 | import ( |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 19 | "flag" |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 20 | "fmt" |
| 21 | "io/ioutil" |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 22 | "net/url" |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 23 | "os" |
| 24 | "regexp" |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 25 | "strconv" |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 26 | "strings" |
| 27 | |
| 28 | "github.com/andygrunwald/go-gerrit" |
| 29 | ) |
| 30 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 31 | // Gerrit is the interface to gerrit |
| 32 | type Gerrit struct { |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 33 | client *gerrit.Client |
| 34 | authenticated bool |
| 35 | } |
| 36 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 37 | // Credentials holds the user name and password used to access Gerrit. |
| 38 | type Credentials struct { |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 39 | Username string |
| 40 | Password string |
| 41 | } |
| 42 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 43 | // Patchset refers to a single gerrit patchset |
| 44 | type 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 Clayton | 5209a81 | 2021-07-30 16:20:46 +0000 | [diff] [blame] | 53 | } |
| 54 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 55 | // ChangeInfo is an alias to gerrit.ChangeInfo |
| 56 | type ChangeInfo = gerrit.ChangeInfo |
| 57 | |
| 58 | // LatestPatchest returns the latest Patchset from the ChangeInfo |
| 59 | func 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 |
| 71 | func (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 Clayton | 526087b | 2022-04-29 15:15:43 +0000 | [diff] [blame] | 78 | // RefsChanges returns the gerrit 'refs/changes/X/Y/Z' string for the patchset |
| 79 | func (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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 90 | // LoadCredentials attempts to load the gerrit credentials for the given gerrit |
| 91 | // URL from the git cookies file. Returns an empty Credentials on failure. |
| 92 | func LoadCredentials(url string) Credentials { |
| 93 | cookiesFile := os.Getenv("HOME") + "/.gitcookies" |
| 94 | if cookies, err := ioutil.ReadFile(cookiesFile); err == nil { |
Ben Clayton | 1c3f88e | 2022-04-26 14:53:42 +0000 | [diff] [blame] | 95 | url := strings.TrimSuffix(strings.TrimPrefix(url, "https://"), "/") |
| 96 | re := regexp.MustCompile(url + `/?\s+(?:FALSE|TRUE)[\s/]+(?:FALSE|TRUE)\s+[0-9]+\s+.\s+(.*)=(.*)`) |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 97 | 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. |
| 107 | func New(url string, cred Credentials) (*Gerrit, error) { |
| 108 | client, err := gerrit.NewClient(url, nil) |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 109 | if err != nil { |
Ben Clayton | 5209a81 | 2021-07-30 16:20:46 +0000 | [diff] [blame] | 110 | return nil, fmt.Errorf("couldn't create gerrit client: %w", err) |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 111 | } |
| 112 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 113 | if cred.Username == "" { |
| 114 | cred = LoadCredentials(url) |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 115 | } |
| 116 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 117 | if cred.Username != "" { |
| 118 | client.Authentication.SetBasicAuth(cred.Username, cred.Password) |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 119 | } |
| 120 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 121 | return &Gerrit{client, cred.Username != ""}, nil |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 122 | } |
| 123 | |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 124 | // QueryChanges returns the changes that match the given query strings. |
| 125 | // See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators |
| 126 | func (g *Gerrit) QueryChanges(querys ...string) (changes []gerrit.ChangeInfo, query string, err error) { |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 127 | changes = []gerrit.ChangeInfo{} |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 128 | query = strings.Join(querys, "+") |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 129 | 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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 135 | return nil, "", g.maybeWrapError(err) |
Ben Clayton | f6660aa | 2021-07-20 20:25:38 +0000 | [diff] [blame] | 136 | } |
| 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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 145 | |
| 146 | // Abandon abandons the change with the given changeID. |
| 147 | func (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. |
| 158 | func (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 Eng | 9d14b16 | 2022-06-10 16:25:43 +0000 | [diff] [blame] | 171 | // EditFiles replaces the content of the files in the given change. It deletes deletedFiles. |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 172 | // If newCommitMsg is not an empty string, then the commit message is replaced |
| 173 | // with the string value. |
Austin Eng | 9d14b16 | 2022-06-10 16:25:43 +0000 | [diff] [blame] | 174 | func (g *Gerrit) EditFiles(changeID, newCommitMsg string, files map[string]string, deletedFiles []string) (Patchset, error) { |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 175 | 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 Eng | 9d14b16 | 2022-06-10 16:25:43 +0000 | [diff] [blame] | 189 | 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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 195 | |
| 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. |
| 205 | func (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 Clayton | 526087b | 2022-04-29 15:15:43 +0000 | [diff] [blame] | 221 | // CommentSide is an enumerator for specifying which side code-comments should |
| 222 | // be shown. |
| 223 | type CommentSide int |
| 224 | |
| 225 | const ( |
| 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 |
| 235 | type 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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 242 | // Comment posts a review comment on the given patchset. |
Ben Clayton | 526087b | 2022-04-29 15:15:43 +0000 | [diff] [blame] | 243 | // If comments is an optional list of file-comments to include in the comment. |
| 244 | func (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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 265 | if err != nil { |
| 266 | return g.maybeWrapError(err) |
| 267 | } |
| 268 | return nil |
| 269 | } |
Ben Clayton | 526087b | 2022-04-29 15:15:43 +0000 | [diff] [blame] | 270 | |
| 271 | // SetReadyForReview marks the change as ready for review. |
| 272 | func (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 Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 282 | func (g *Gerrit) maybeWrapError(err error) error { |
| 283 | if err != nil && !g.authenticated { |
| 284 | return fmt.Errorf(`query failed, possibly because of authentication. |
James Price | b7179aa | 2023-03-23 19:03:29 +0000 | [diff] [blame] | 285 | See https://dawn.googlesource.com/new-password for obtaining a username |
Ben Clayton | 53ddabe | 2022-04-12 16:13:31 +0000 | [diff] [blame] | 286 | and password which can be provided with --gerrit-user and --gerrit-pass. |
| 287 | Note: This tool will scan ~/.gitcookies for credentials. |
| 288 | %w`, err) |
| 289 | } |
| 290 | return err |
| 291 | } |