v.io/jiri@v0.0.0-20160715023856-abfb8b131290/gerrit/gerrit.go (about) 1 // Copyright 2015 The Vanadium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package gerrit provides library functions for interacting with the 6 // gerrit code review system. 7 package gerrit 8 9 import ( 10 "bufio" 11 "bytes" 12 "encoding/json" 13 "fmt" 14 "io" 15 "io/ioutil" 16 "net/http" 17 "net/url" 18 "regexp" 19 "strconv" 20 "strings" 21 22 "v.io/jiri/collect" 23 "v.io/jiri/gitutil" 24 "v.io/jiri/runutil" 25 ) 26 27 var ( 28 autosubmitRE = regexp.MustCompile("AutoSubmit") 29 remoteRE = regexp.MustCompile("remote:[^\n]*") 30 multiPartRE = regexp.MustCompile(`MultiPart:\s*(\d+)\s*/\s*(\d+)`) 31 presubmitTestRE = regexp.MustCompile(`PresubmitTest:\s*(.*)`) 32 33 queryParameters = []string{"CURRENT_REVISION", "CURRENT_COMMIT", "CURRENT_FILES", "LABELS", "DETAILED_ACCOUNTS"} 34 ) 35 36 // Comment represents a single inline file comment. 37 type Comment struct { 38 Line int `json:"line,omitempty"` 39 Message string `json:"message,omitempty"` 40 } 41 42 // Review represents a Gerrit review. For more details, see: 43 // http://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input 44 type Review struct { 45 Message string `json:"message,omitempty"` 46 Labels map[string]string `json:"labels,omitempty"` 47 Comments map[string][]Comment `json:"comments,omitempty"` 48 } 49 50 // CLOpts records the review options. 51 type CLOpts struct { 52 // Autosubmit determines if the CL should be auto-submitted when it 53 // meets the submission rules. 54 Autosubmit bool 55 // Branch identifies the local branch that contains the CL. 56 Branch string 57 // Ccs records a list of email addresses to cc on the CL. 58 Ccs []string 59 // Draft determines if this CL is a draft. 60 Draft bool 61 // Edit determines if the user should be prompted to edit the commit 62 // message when the CL is exported to Gerrit. 63 Edit bool 64 // Remote identifies the Gerrit remote that this CL will be pushed to 65 Remote string 66 // Host identifies the Gerrit host. 67 Host *url.URL 68 // Presubmit determines what presubmit tests to run. 69 Presubmit PresubmitTestType 70 // RemoteBranch identifies the remote branch the CL pertains to. 71 RemoteBranch string 72 // Reviewers records a list of email addresses of CL reviewers. 73 Reviewers []string 74 // Topic records the CL topic. 75 Topic string 76 // Verify controls whether git pre-push hooks should be run before uploading. 77 Verify bool 78 } 79 80 // Gerrit records a hostname of a Gerrit instance. 81 type Gerrit struct { 82 host *url.URL 83 s runutil.Sequence 84 } 85 86 // New is the Gerrit factory. 87 func New(s runutil.Sequence, host *url.URL) *Gerrit { 88 return &Gerrit{ 89 host: host, 90 s: s, 91 } 92 } 93 94 // PostReview posts a review to the given Gerrit reference. 95 func (g *Gerrit) PostReview(ref string, message string, labels map[string]string) (e error) { 96 cred, err := hostCredentials(g.s, g.host) 97 if err != nil { 98 return err 99 } 100 101 review := Review{ 102 Message: message, 103 Labels: labels, 104 } 105 106 // Encode "review" as JSON. 107 encodedBytes, err := json.Marshal(review) 108 if err != nil { 109 return fmt.Errorf("Marshal(%#v) failed: %v", review, err) 110 } 111 112 // Construct API URL. 113 // ref is in the form of "refs/changes/<last two digits of change number>/<change number>/<patch set number>". 114 parts := strings.Split(ref, "/") 115 if expected, got := 5, len(parts); expected != got { 116 return fmt.Errorf("unexpected number of %q parts: expected %v, got %v", ref, expected, got) 117 } 118 cl, revision := parts[3], parts[4] 119 url := fmt.Sprintf("%s/a/changes/%s/revisions/%s/review", g.host, cl, revision) 120 121 // Post the review. 122 method, body := "POST", bytes.NewReader(encodedBytes) 123 req, err := http.NewRequest(method, url, body) 124 if err != nil { 125 return fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) 126 } 127 req.Header.Add("Content-Type", "application/json;charset=UTF-8") 128 req.SetBasicAuth(cred.username, cred.password) 129 res, err := http.DefaultClient.Do(req) 130 if err != nil { 131 return fmt.Errorf("Do(%v) failed: %v", req, err) 132 } 133 if res.StatusCode != http.StatusOK { 134 return fmt.Errorf("PostReview:Do(%v) failed: %v", req, res.StatusCode) 135 } 136 defer collect.Error(func() error { return res.Body.Close() }, &e) 137 138 return nil 139 } 140 141 type Topic struct { 142 Topic string `json:"topic"` 143 } 144 145 // SetTopic sets the topic of the given Gerrit reference. 146 func (g *Gerrit) SetTopic(cl string, opts CLOpts) (e error) { 147 cred, err := hostCredentials(g.s, g.host) 148 if err != nil { 149 return err 150 } 151 topic := Topic{opts.Topic} 152 data, err := json.Marshal(topic) 153 if err != nil { 154 return fmt.Errorf("Marshal(%#v) failed: %v", topic, err) 155 } 156 157 url := fmt.Sprintf("%s/a/changes/%s/topic", g.host, cl) 158 method, body := "PUT", bytes.NewReader(data) 159 req, err := http.NewRequest(method, url, body) 160 if err != nil { 161 return fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) 162 } 163 req.Header.Add("Content-Type", "application/json;charset=UTF-8") 164 req.SetBasicAuth(cred.username, cred.password) 165 res, err := http.DefaultClient.Do(req) 166 if err != nil { 167 return fmt.Errorf("Do(%v) failed: %v", req, err) 168 } 169 if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { 170 return fmt.Errorf("SetTopic:Do(%v) failed: %v", req, res.StatusCode) 171 } 172 defer collect.Error(func() error { return res.Body.Close() }, &e) 173 174 return nil 175 } 176 177 // The following types reflect the schema Gerrit uses to represent 178 // CLs. 179 type CLList []Change 180 type CLRefMap map[string]Change 181 type Change struct { 182 // CL data. 183 Change_id string 184 Current_revision string 185 Project string 186 Topic string 187 Revisions Revisions 188 Owner Owner 189 Labels map[string]map[string]interface{} 190 191 // Custom labels. 192 AutoSubmit bool 193 MultiPart *MultiPartCLInfo 194 PresubmitTest PresubmitTestType 195 } 196 type Revisions map[string]Revision 197 type Revision struct { 198 Fetch `json:"fetch"` 199 Commit `json:"commit"` 200 Files `json:"files"` 201 } 202 type Fetch struct { 203 Http `json:"http"` 204 } 205 type Http struct { 206 Ref string 207 } 208 type Commit struct { 209 Message string 210 } 211 type Owner struct { 212 Email string 213 } 214 type Files map[string]struct{} 215 type ChangeError struct { 216 Err error 217 CL Change 218 } 219 220 func (ce *ChangeError) Error() string { 221 return ce.Err.Error() 222 } 223 224 func NewChangeError(cl Change, err error) *ChangeError { 225 return &ChangeError{err, cl} 226 } 227 228 func (c Change) Reference() string { 229 return c.Revisions[c.Current_revision].Fetch.Http.Ref 230 } 231 232 func (c Change) OwnerEmail() string { 233 return c.Owner.Email 234 } 235 236 type PresubmitTestType string 237 238 const ( 239 PresubmitTestTypeNone PresubmitTestType = "none" 240 PresubmitTestTypeAll PresubmitTestType = "all" 241 ) 242 243 func PresubmitTestTypes() []string { 244 return []string{string(PresubmitTestTypeNone), string(PresubmitTestTypeAll)} 245 } 246 247 // parseQueryResults parses a list of Gerrit ChangeInfo entries (json 248 // result of a query) and returns a list of Change entries. 249 func parseQueryResults(reader io.Reader) (CLList, error) { 250 r := bufio.NewReader(reader) 251 252 // The first line of the input is the XSSI guard 253 // ")]}'". Getting rid of that. 254 if _, err := r.ReadSlice('\n'); err != nil { 255 return nil, err 256 } 257 258 // Parse the remaining input to construct a slice of Change objects 259 // to return. 260 var changes CLList 261 if err := json.NewDecoder(r).Decode(&changes); err != nil { 262 return nil, fmt.Errorf("Decode() failed: %v", err) 263 } 264 265 newChanges := CLList{} 266 for _, change := range changes { 267 clMessage := change.Revisions[change.Current_revision].Commit.Message 268 multiPartCLInfo, err := parseMultiPartMatch(clMessage) 269 if err != nil { 270 return nil, err 271 } 272 if multiPartCLInfo != nil { 273 multiPartCLInfo.Topic = change.Topic 274 } 275 change.MultiPart = multiPartCLInfo 276 change.PresubmitTest = parsePresubmitTestType(clMessage) 277 change.AutoSubmit = autosubmitRE.FindStringSubmatch(clMessage) != nil 278 newChanges = append(newChanges, change) 279 } 280 return newChanges, nil 281 } 282 283 // parseMultiPartMatch uses multiPartRE (a pattern like: MultiPart: 1/3) to match the given string. 284 func parseMultiPartMatch(match string) (*MultiPartCLInfo, error) { 285 matches := multiPartRE.FindStringSubmatch(match) 286 if matches != nil { 287 index, err := strconv.Atoi(matches[1]) 288 if err != nil { 289 return nil, fmt.Errorf("Atoi(%q) failed: %v", matches[1], err) 290 } 291 total, err := strconv.Atoi(matches[2]) 292 if err != nil { 293 return nil, fmt.Errorf("Atoi(%q) failed: %v", matches[2], err) 294 } 295 return &MultiPartCLInfo{ 296 Index: index, 297 Total: total, 298 }, nil 299 } 300 return nil, nil 301 } 302 303 // parsePresubmitTestType uses presubmitTestRE to match the given string and 304 // returns the presubmit test type. 305 func parsePresubmitTestType(match string) PresubmitTestType { 306 ret := PresubmitTestTypeAll 307 matches := presubmitTestRE.FindStringSubmatch(match) 308 if matches != nil { 309 switch matches[1] { 310 case string(PresubmitTestTypeNone): 311 ret = PresubmitTestTypeNone 312 case string(PresubmitTestTypeAll): 313 ret = PresubmitTestTypeAll 314 } 315 } 316 return ret 317 } 318 319 // Query returns a list of QueryResult entries matched by the given 320 // Gerrit query string from the given Gerrit instance. The result is 321 // sorted by the last update time, most recently updated to oldest 322 // updated. 323 // 324 // See the following links for more details about Gerrit search syntax: 325 // - https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes 326 // - https://gerrit-review.googlesource.com/Documentation/user-search.html 327 func (g *Gerrit) Query(query string) (_ CLList, e error) { 328 cred, err := hostCredentials(g.s, g.host) 329 if err != nil { 330 return nil, err 331 } 332 333 u, err := url.Parse(g.host.String()) 334 if err != nil { 335 return nil, err 336 } 337 u.Path = "/a/changes/" 338 v := url.Values{} 339 v.Set("q", query) 340 for _, o := range queryParameters { 341 v.Add("o", o) 342 } 343 u.RawQuery = v.Encode() 344 url := u.String() 345 346 var body io.Reader 347 method, body := "GET", nil 348 req, err := http.NewRequest(method, url, body) 349 if err != nil { 350 return nil, fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) 351 } 352 req.Header.Add("Accept", "application/json") 353 req.SetBasicAuth(cred.username, cred.password) 354 355 res, err := http.DefaultClient.Do(req) 356 if err != nil { 357 return nil, fmt.Errorf("Do(%v) failed: %v", req, err) 358 } 359 if res.StatusCode != http.StatusOK { 360 return nil, fmt.Errorf("Query:Do(%v) failed: %v", req, res.StatusCode) 361 } 362 defer collect.Error(func() error { return res.Body.Close() }, &e) 363 return parseQueryResults(res.Body) 364 } 365 366 // GetChange returns a Change object for the given changeId number. 367 func (g *Gerrit) GetChange(changeNumber int) (*Change, error) { 368 clList, err := g.Query(fmt.Sprintf("%d", changeNumber)) 369 if err != nil { 370 return nil, err 371 } 372 if len(clList) == 0 { 373 return nil, fmt.Errorf("Query for change '%d' returned no results", changeNumber) 374 } 375 if len(clList) > 1 { 376 // Based on cursory testing with Gerrit, I don't expect this to ever happen, but in 377 // case it does, I'm raising an error to inspire investigation. -- lanechr 378 return nil, fmt.Errorf("Too many changes returned for query '%d'", changeNumber) 379 } 380 return &clList[0], nil 381 } 382 383 // Submit submits the given changelist through Gerrit. 384 func (g *Gerrit) Submit(changeID string) (e error) { 385 cred, err := hostCredentials(g.s, g.host) 386 if err != nil { 387 return err 388 } 389 390 // Encode data needed for Submit. 391 data := struct { 392 WaitForMerge bool `json:"wait_for_merge"` 393 }{ 394 WaitForMerge: true, 395 } 396 encodedBytes, err := json.Marshal(data) 397 if err != nil { 398 return fmt.Errorf("Marshal(%#v) failed: %v", data, err) 399 } 400 401 // Call Submit API. 402 // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-change 403 url := fmt.Sprintf("%s/a/changes/%s/submit", g.host, changeID) 404 var body io.Reader 405 method, body := "POST", bytes.NewReader(encodedBytes) 406 req, err := http.NewRequest(method, url, body) 407 if err != nil { 408 return fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) 409 } 410 req.Header.Add("Content-Type", "application/json;charset=UTF-8") 411 req.SetBasicAuth(cred.username, cred.password) 412 413 res, err := http.DefaultClient.Do(req) 414 if err != nil { 415 return fmt.Errorf("Do(%v) failed: %v", req, err) 416 } 417 if res.StatusCode != http.StatusOK { 418 return fmt.Errorf("Submit:Do(%v) failed: %v", req, res.StatusCode) 419 } 420 defer collect.Error(func() error { return res.Body.Close() }, &e) 421 422 // Check response. 423 bytes, err := ioutil.ReadAll(res.Body) 424 if err != nil { 425 return err 426 } 427 resContent := string(bytes) 428 // For a "TBR" CL, the response code is not 200 but the submit will still succeed. 429 // In those cases, the "error" message will be "change is new". 430 // We don't treat this case as error. 431 if res.StatusCode != http.StatusOK && strings.TrimSpace(resContent) != "change is new" { 432 return fmt.Errorf("Failed to submit CL %q:\n%s", changeID, resContent) 433 } 434 435 return nil 436 } 437 438 // formatParams formats parameters of a change list. 439 func formatParams(params []string, key string) []string { 440 var keyedParams []string 441 for _, param := range params { 442 keyedParams = append(keyedParams, key+"="+param) 443 } 444 return keyedParams 445 } 446 447 // Reference inputs CL options and returns a matching string 448 // representation of a Gerrit reference. 449 func Reference(opts CLOpts) string { 450 var ref string 451 if opts.Draft { 452 ref = "refs/drafts/" + opts.RemoteBranch 453 } else { 454 ref = "refs/for/" + opts.RemoteBranch 455 } 456 var params []string 457 params = append(params, formatParams(opts.Reviewers, "r")...) 458 params = append(params, formatParams(opts.Ccs, "cc")...) 459 if len(params) > 0 { 460 ref = ref + "%" + strings.Join(params, ",") 461 } 462 return ref 463 } 464 465 // Push pushes the current branch to Gerrit. 466 func Push(seq runutil.Sequence, clOpts CLOpts) error { 467 refspec := "HEAD:" + Reference(clOpts) 468 args := []string{"push", clOpts.Remote, refspec} 469 // TODO(jamesr): This should really reuse gitutil/git.go's Push which knows 470 // how to set this option but doesn't yet know how to pipe stdout/stderr the way 471 // this function wants. 472 if clOpts.Verify { 473 args = append(args, "--verify") 474 } else { 475 args = append(args, "--no-verify") 476 } 477 var stdout, stderr bytes.Buffer 478 if err := seq.Capture(&stdout, &stderr).Last("git", args...); err != nil { 479 return gitutil.Error(stdout.String(), stderr.String(), args...) 480 } 481 for _, line := range strings.Split(stderr.String(), "\n") { 482 if remoteRE.MatchString(line) { 483 fmt.Println(line) 484 } 485 } 486 return nil 487 } 488 489 // ParseRefString parses the cl and patchset number from the given ref string. 490 func ParseRefString(ref string) (int, int, error) { 491 parts := strings.Split(ref, "/") 492 if expected, got := 5, len(parts); expected != got { 493 return -1, -1, fmt.Errorf("unexpected number of %q parts: expected %v, got %v", ref, expected, got) 494 } 495 cl, err := strconv.Atoi(parts[3]) 496 if err != nil { 497 return -1, -1, fmt.Errorf("Atoi(%q) failed: %v", parts[3], err) 498 } 499 patchset, err := strconv.Atoi(parts[4]) 500 if err != nil { 501 return -1, -1, fmt.Errorf("Atoi(%q) failed: %v", parts[4], err) 502 } 503 return cl, patchset, nil 504 }