forked from golang/build
-
Notifications
You must be signed in to change notification settings - Fork 1
/
gerrit.go
616 lines (537 loc) · 18.8 KB
/
gerrit.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
// 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.
//
// The API is not subject to the Go 1 compatibility promise and may change at
// any time.
package gerrit
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"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/a" (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.
// 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
}
// HTTPError is the error type returned when a Gerrit API call does not return
// the expected status.
type HTTPError struct {
Res *http.Response
Body []byte // 4KB prefix
BodyErr error // any error reading Body
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP status %s; %s", e.Res.Status, e.Body)
}
// doArg is one of urlValues, reqBody, or wantResStatus
type doArg interface {
isDoArg()
}
type wantResStatus int
func (wantResStatus) isDoArg() {}
type reqBody struct{ body interface{} }
func (reqBody) isDoArg() {}
type urlValues url.Values
func (urlValues) isDoArg() {}
func (c *Client) do(ctx context.Context, dst interface{}, method, path string, opts ...doArg) error {
var arg url.Values
var body interface{}
var wantStatus = http.StatusOK
for _, opt := range opts {
switch opt := opt.(type) {
case wantResStatus:
wantStatus = int(opt)
case reqBody:
body = opt.body
case urlValues:
arg = url.Values(opt)
default:
panic(fmt.Sprintf("internal error; unsupported type %T", opt))
}
}
var bodyr io.Reader
var contentType string
if body != nil {
v, err := json.MarshalIndent(body, "", " ")
if err != nil {
return err
}
bodyr = bytes.NewReader(v)
contentType = "application/json"
}
// 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 = ""
}
var err error
u := c.url + slashA + path
if arg != nil {
u += "?" + arg.Encode()
}
req, err := http.NewRequest(method, u, bodyr)
if err != nil {
return err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
c.auth.setAuth(c, req)
res, err := c.httpClient().Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != wantStatus {
body, err := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10))
return &HTTPError{res, body, err}
}
// 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)
}
// ChangeInfo is a Gerrit data structure.
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
type ChangeInfo struct {
// ID is the ID of the change in the format
// "'<project>~<branch>~<Change-Id>'", where 'project',
// 'branch' and 'Change-Id' are URL encoded. For 'branch' the
// refs/heads/ prefix is omitted.
ID string `json:"id"`
ChangeNumber int `json:"_number"`
Project string `json:"project"`
// Branch is the name of the target branch.
// The refs/heads/ prefix is omitted.
Branch string `json:"branch"`
ChangeID string `json:"change_id"`
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"`
Mergable bool `json:"mergable"`
// 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 map[string]LabelInfo `json:"labels"`
// TODO: more as needed
// MoreChanges is set on the last change from QueryChanges if
// the result set is truncated by an 'n' parameter.
MoreChanges bool `json:"_more_changes"`
}
type AccountInfo struct {
NumericID int64 `json:"_account_id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Username string `json:"username,omitempty"`
}
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"`
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:
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"`
// 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"`
}
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
// 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,
})
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
// If the change doesn't exist, the error will be ErrChangeNotExist.
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")
}
change := new(ChangeInfo)
err := c.do(ctx, change, "GET", "/changes/"+changeID, urlValues{
"n": condInt(opt.N),
"o": opt.Fields,
})
if he, ok := err.(*HTTPError); ok && he.Res.StatusCode == 404 {
return nil, ErrChangeNotExist
}
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
}
// 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"`
// 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"`
}
// 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"`
Message string `json:"message"`
// 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),
reqBody{review})
}
// AbandonChange abandons the given change.
func (c *Client) AbandonChange(ctx context.Context, changeID string) error {
var change ChangeInfo
return c.do(ctx, &change, "POST", "/changes/"+changeID+"/abandon")
}
// 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"`
}
// ListProjects returns the server's active projects.
//
// The returned slice is sorted by project ID and excludes the "All-Projects" project.
//
// 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", fmt.Sprintf("/projects/"))
if err != nil {
return nil, err
}
var ret []ProjectInfo
for name, pi := range res {
if name == "All-Projects" {
continue
}
if pi.State != "ACTIVE" {
continue
}
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", name), reqBody{&pi}, wantResStatus(http.StatusCreated))
return res, err
}
// ErrProjectNotExist is returned when a project doesn't exist.
// It is not necessarily returned unless a method is documented as
// returning it.
var ErrProjectNotExist = errors.New("gerrit: requested project does not exist")
// ErrChangeNotExist is returned when a change doesn't exist.
// It is not necessarily returned unless a method is documented as
// returning it.
var ErrChangeNotExist = errors.New("gerrit: requested change does not exist")
// GetProjectInfo returns info about a project.
// If the project doesn't exist, the error will be ErrProjectNotExist.
func (c *Client) GetProjectInfo(ctx context.Context, name string) (ProjectInfo, error) {
var res ProjectInfo
err := c.do(ctx, &res, "GET", fmt.Sprintf("/projects/%s", name))
if he, ok := err.(*HTTPError); ok && he.Res.StatusCode == 404 {
return res, ErrProjectNotExist
}
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 a project's branches.
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/", name))
if err != nil {
return nil, err
}
m := map[string]BranchInfo{}
for _, bi := range res {
m[bi.Ref] = bi
}
return m, nil
}
// 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
}
// GetProjects returns a map of all projects on the Gerrit server.
func (c *Client) GetProjects(ctx context.Context, branch string) (map[string]*ProjectInfo, error) {
mp := make(map[string]*ProjectInfo)
err := c.do(ctx, &mp, "GET", fmt.Sprintf("?b=%s&format=JSON", branch))
return mp, err
}
type TimeStamp time.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) 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
}