| package gerrit |
| |
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "os/user" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/golang/groupcache/lru" |
| buildbucketpb "go.chromium.org/luci/buildbucket/proto" |
| "go.skia.org/infra/go/auth" |
| "go.skia.org/infra/go/buildbucket" |
| "go.skia.org/infra/go/gitauth" |
| "go.skia.org/infra/go/httputils" |
| "go.skia.org/infra/go/skerr" |
| "go.skia.org/infra/go/sklog" |
| "go.skia.org/infra/go/util" |
| ) |
| |
| var ( |
| ErrNotFound = errors.New("Requested item was not found") |
| ) |
| |
| const ( |
| TIME_FORMAT = "2006-01-02 15:04:05.999999" |
| GERRIT_CHROMIUM_URL = "https://chromium-review.googlesource.com" |
| GERRIT_SKIA_URL = "https://skia-review.googlesource.com" |
| MAX_GERRIT_LIMIT = 500 |
| |
| AUTH_SCOPE = auth.SCOPE_GERRIT |
| |
| CHANGE_STATUS_ABANDONED = "ABANDONED" |
| CHANGE_STATUS_DRAFT = "DRAFT" |
| CHANGE_STATUS_MERGED = "MERGED" |
| CHANGE_STATUS_NEW = "NEW" |
| |
| // Gerrit labels. |
| CODEREVIEW_LABEL = "Code-Review" |
| CODEREVIEW_LABEL_DISAPPROVE = -1 |
| CODEREVIEW_LABEL_NONE = 0 |
| CODEREVIEW_LABEL_APPROVE = 1 |
| CODEREVIEW_LABEL_SELF_APPROVE = 2 // Used by ANGLE, not Chromium or Skia. |
| |
| // Chromium specific labels. |
| COMMITQUEUE_LABEL = "Commit-Queue" |
| COMMITQUEUE_LABEL_NONE = 0 |
| COMMITQUEUE_LABEL_DRY_RUN = 1 |
| COMMITQUEUE_LABEL_SUBMIT = 2 |
| |
| // Android specific labels. |
| AUTOSUBMIT_LABEL = "Autosubmit" |
| AUTOSUBMIT_LABEL_NONE = 0 |
| AUTOSUBMIT_LABEL_SUBMIT = 1 |
| PRESUBMIT_READY_LABEL = "Presubmit-Ready" |
| PRESUBMIT_READY_LABEL_NONE = 0 |
| PRESUBMIT_READY_LABEL_ENABLE = 1 |
| PRESUBMIT_VERIFIED_LABEL = "Presubmit-Verified" |
| PRESUBMIT_VERIFIED_LABEL_REJECTED = -1 |
| PRESUBMIT_VERIFIED_LABEL_RUNNING = 0 |
| PRESUBMIT_VERIFIED_LABEL_ACCEPTED = 1 |
| |
| URL_TMPL_CHANGE = "/changes/%d/detail?o=ALL_REVISIONS" |
| |
| // Kinds of patchsets. |
| PATCHSET_KIND_MERGE_FIRST_PARENT_UPDATE = "MERGE_FIRST_PARENT_UPDATE" |
| PATCHSET_KIND_NO_CHANGE = "NO_CHANGE" |
| PATCHSET_KIND_NO_CODE_CHANGE = "NO_CODE_CHANGE" |
| PATCHSET_KIND_REWORK = "REWORK" |
| PATCHSET_KIND_TRIVIAL_REBASE = "TRIVIAL_REBASE" |
| |
| // extractReg is the regular expression used by ExtractIssueFromCommit. |
| extractRegTmpl = `^\s*Reviewed-on:.*%s.*/([0-9]+)\s*$` |
| ) |
| |
| var ( |
| TRIVIAL_PATCHSET_KINDS = []string{ |
| PATCHSET_KIND_TRIVIAL_REBASE, |
| PATCHSET_KIND_NO_CHANGE, |
| PATCHSET_KIND_NO_CODE_CHANGE, |
| } |
| ) |
| |
| // ChangeInfo contains information about a Gerrit issue. |
| type ChangeInfo struct { |
| Id string `json:"id"` |
| Created time.Time `json:"-"` |
| CreatedString string `json:"created"` |
| Updated time.Time `json:"-"` |
| UpdatedString string `json:"updated"` |
| Submitted time.Time `json:"-"` |
| SubmittedString string `json:"submitted"` |
| Project string `json:"project"` |
| ChangeId string `json:"change_id"` |
| Subject string `json:"subject"` |
| Branch string `json:"branch"` |
| Committed bool `json:"committed"` |
| Revisions map[string]*Revision `json:"revisions"` |
| Patchsets []*Revision `json:"-"` |
| MoreChanges bool `json:"_more_changes"` |
| Issue int64 `json:"_number"` |
| Labels map[string]*LabelEntry `json:"labels"` |
| Owner *Owner `json:"owner"` |
| Status string `json:"status"` |
| WorkInProgress bool `json:"work_in_progress"` |
| } |
| |
| // Find the set of non-trivial patchsets. Returns the Revisions in order of |
| // patchset number. Note that this is only correct for Chromium Gerrit instances |
| // because it makes Chromium-specific assumptions. |
| func (ci *ChangeInfo) GetNonTrivialPatchSets() []*Revision { |
| allPatchSets := make([]int, 0, len(ci.Revisions)) |
| byNumber := make(map[int]*Revision, len(ci.Revisions)) |
| for _, rev := range ci.Revisions { |
| allPatchSets = append(allPatchSets, int(rev.Number)) |
| byNumber[int(rev.Number)] = rev |
| } |
| sort.Ints(allPatchSets) |
| rv := make([]*Revision, 0, len(ci.Revisions)) |
| for idx, num := range allPatchSets { |
| rev := byNumber[num] |
| // Skip the last patch set for merged CLs, since it is auto- |
| // generated for Chromium projects. |
| if ci.Status == CHANGE_STATUS_MERGED && idx == len(allPatchSets)-1 { |
| continue |
| } |
| if !util.In(rev.Kind, TRIVIAL_PATCHSET_KINDS) { |
| rv = append(rv, rev) |
| } |
| } |
| return rv |
| } |
| |
| // The RelatedChangesInfo entity contains information about related changes. |
| type RelatedChangesInfo struct { |
| Changes []*RelatedChangeAndCommitInfo `json:"changes"` |
| } |
| |
| // RelatedChangeAndCommitInfo entity contains information about a related change and commit. |
| type RelatedChangeAndCommitInfo struct { |
| ChangeId string `json:"change_id"` |
| Issue int64 `json:"_change_number"` |
| Revision int64 `json:"_revision_number"` |
| Status string `json:"status"` |
| } |
| |
| // IsClosed returns true iff the issue corresponding to the ChangeInfo is |
| // abandoned or merged. |
| func (ci *ChangeInfo) IsClosed() bool { |
| return (ci.Status == CHANGE_STATUS_ABANDONED || |
| ci.Status == CHANGE_STATUS_MERGED) |
| } |
| |
| // IsMerged returns true iff the issue corresponding to the ChangeInfo is |
| // merged. |
| func (ci *ChangeInfo) IsMerged() bool { |
| return ci.Status == CHANGE_STATUS_MERGED |
| } |
| |
| // Owner gathers the owner information of a ChangeInfo instance. Some fields omitted. |
| type Owner struct { |
| Email string `json:"email"` |
| } |
| |
| type LabelEntry struct { |
| All []*LabelDetail |
| Values map[string]string |
| DefaultValue int |
| } |
| |
| type LabelDetail struct { |
| Name string |
| Email string |
| Date string |
| Value int |
| } |
| |
| 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"` |
| SizeDelta int `json:"size_delta"` |
| Size int `json:"size"` |
| } |
| |
| // Revision is the information associated with a patchset in Gerrit. |
| type Revision struct { |
| ID string `json:"-"` |
| Number int64 `json:"_number"` |
| CreatedString string `json:"created"` |
| Created time.Time `json:"-"` |
| Kind string `json:"kind"` |
| } |
| |
| type GerritInterface interface { |
| Abandon(context.Context, *ChangeInfo, string) error |
| AddComment(context.Context, *ChangeInfo, string) error |
| Approve(context.Context, *ChangeInfo, string) error |
| Config() *Config |
| CreateChange(context.Context, string, string, string, string) (*ChangeInfo, error) |
| DeleteChangeEdit(context.Context, *ChangeInfo) error |
| DeleteFile(context.Context, *ChangeInfo, string) error |
| DisApprove(context.Context, *ChangeInfo, string) error |
| EditFile(context.Context, *ChangeInfo, string, string) error |
| ExtractIssueFromCommit(string) (int64, error) |
| Files(ctx context.Context, issue int64, patch string) (map[string]*FileInfo, error) |
| GetFileNames(ctx context.Context, issue int64, patch string) ([]string, error) |
| GetIssueProperties(context.Context, int64) (*ChangeInfo, error) |
| GetPatch(context.Context, int64, string) (string, error) |
| GetRepoUrl() string |
| GetTrybotResults(context.Context, int64, int64) ([]*buildbucketpb.Build, error) |
| GetUserEmail(context.Context) (string, error) |
| Initialized() bool |
| IsBinaryPatch(ctx context.Context, issue int64, patch string) (bool, error) |
| MoveFile(context.Context, *ChangeInfo, string, string) error |
| NoScore(context.Context, *ChangeInfo, string) error |
| PublishChangeEdit(context.Context, *ChangeInfo) error |
| RemoveFromCQ(context.Context, *ChangeInfo, string) error |
| Search(context.Context, int, ...*SearchTerm) ([]*ChangeInfo, error) |
| SelfApprove(context.Context, *ChangeInfo, string) error |
| SendToCQ(context.Context, *ChangeInfo, string) error |
| SendToDryRun(context.Context, *ChangeInfo, string) error |
| SetCommitMessage(context.Context, *ChangeInfo, string) error |
| SetReadyForReview(context.Context, *ChangeInfo) error |
| SetReview(context.Context, *ChangeInfo, string, map[string]int, []string) error |
| SetTopic(context.Context, string, int64) error |
| Submit(context.Context, *ChangeInfo) error |
| TurnOnAuthenticatedGets() |
| Url(int64) string |
| } |
| |
| // Gerrit is an object used for interacting with the issue tracker. |
| type Gerrit struct { |
| cfg *Config |
| client *http.Client |
| BuildbucketClient *buildbucket.Client |
| gitCookiesPath string |
| url string |
| useAuthenticatedGets bool |
| extractRegEx *regexp.Regexp |
| } |
| |
| // NewGerrit returns a new Gerrit instance. If gitCookiesPath is empty the |
| // instance will be in read-only mode and only return information available to |
| // anonymous users. |
| func NewGerrit(gerritUrl, gitCookiesPath string, client *http.Client) (*Gerrit, error) { |
| return NewGerritWithConfig(CONFIG_CHROMIUM, gerritUrl, gitCookiesPath, client) |
| } |
| |
| // NewGerritWithConfig returns a new Gerrit instance which uses the given |
| // Config. If gitCookiesPath is empty the instance will be in read-only mode and |
| // only return information available to anonymous users. |
| func NewGerritWithConfig(cfg *Config, gerritUrl, gitCookiesPath string, client *http.Client) (*Gerrit, error) { |
| parsedUrl, err := url.Parse(gerritUrl) |
| if err != nil { |
| return nil, skerr.Fmt("Unable to parse gerrit URL: %s", err) |
| } |
| |
| regExStr := fmt.Sprintf(extractRegTmpl, parsedUrl.Host) |
| extractRegEx, err := regexp.Compile(regExStr) |
| if err != nil { |
| return nil, skerr.Fmt("Unable to compile regular expression '%s'. Error: %s", regExStr, err) |
| } |
| |
| if client == nil { |
| client = httputils.NewTimeoutClient() |
| } |
| return &Gerrit{ |
| cfg: cfg, |
| url: gerritUrl, |
| client: client, |
| BuildbucketClient: buildbucket.NewClient(client), |
| gitCookiesPath: gitCookiesPath, |
| extractRegEx: extractRegEx, |
| }, nil |
| } |
| |
| // Config returns the Config object used by this Gerrit. |
| func (g *Gerrit) Config() *Config { |
| return g.cfg |
| } |
| |
| // DefaultGitCookiesPath returns the default cookie file. The return value |
| // can be used as the input to NewGerrit. If it cannot be retrieved an |
| // error will be logged and the empty string is returned. |
| func DefaultGitCookiesPath() string { |
| usr, err := user.Current() |
| if err != nil { |
| sklog.Errorf("Unable to retrieve default git cookies path") |
| return "" |
| } |
| return filepath.Join(usr.HomeDir, ".gitcookies") |
| } |
| |
| // GitCookieAuthDaemonPath returns the default path that git_cookie_authdaemon |
| // writes to. See infra/git_cookie_authdaemon |
| func GitCookieAuthDaemonPath() (string, error) { |
| usr, err := user.Current() |
| if err != nil { |
| return "", skerr.Fmt("Unable to retrieve user for git_auth_deamon default cookie path.") |
| } |
| return filepath.Join(usr.HomeDir, ".git-credential-cache", "cookie"), nil |
| } |
| |
| func parseTime(t string) time.Time { |
| parsed, _ := time.Parse(TIME_FORMAT, t) |
| return parsed |
| } |
| |
| // Initialized returns false if the implementation of GerritInterface has not |
| // been initialized (i.e. it is a pointer to nil). |
| func (g *Gerrit) Initialized() bool { |
| return g != nil |
| } |
| |
| // TurnOnAuthenticatedGets makes all GET requests contain authentication |
| // cookies. By default only POST requests are automatically authenticated. |
| func (g *Gerrit) TurnOnAuthenticatedGets() { |
| g.useAuthenticatedGets = true |
| } |
| |
| // Url returns the url of the Gerrit issue identified by issueID or the |
| // base URL of the Gerrit instance if issueID is 0. |
| func (g *Gerrit) Url(issueID int64) string { |
| if issueID == 0 { |
| return g.url |
| } |
| return fmt.Sprintf("%s/c/%d", g.url, issueID) |
| } |
| |
| type AccountDetails struct { |
| AccountId int64 `json:"_account_id"` |
| Name string `json:"name"` |
| Email string `json:"email"` |
| UserName string `json:"username"` |
| } |
| |
| // GetUserEmail returns the Gerrit user's email address. |
| func (g *Gerrit) GetUserEmail(ctx context.Context) (string, error) { |
| g.TurnOnAuthenticatedGets() |
| url := "/accounts/self/detail" |
| var account AccountDetails |
| if err := g.get(ctx, url, &account, nil); err != nil { |
| return "", fmt.Errorf("Failed to retrieve user: %s", err) |
| } |
| return account.Email, nil |
| } |
| |
| // GetRepoUrl returns the url of the Googlesource repo. |
| func (g *Gerrit) GetRepoUrl() string { |
| return strings.Replace(g.url, "-review", "", 1) |
| } |
| |
| // ExtractIssueFromCommit returns the issue id by parsing the commit message of |
| // a landed commit. It expects the commit message to contain one line in this format: |
| // |
| // Reviewed-on: https://skia-review.googlesource.com/999999 |
| // |
| // where the digits at the end are the issue id. |
| func (g *Gerrit) ExtractIssueFromCommit(commitMsg string) (int64, error) { |
| scanner := bufio.NewScanner(bytes.NewBuffer([]byte(commitMsg))) |
| for scanner.Scan() { |
| line := scanner.Text() |
| result := g.extractRegEx.FindStringSubmatch(line) |
| if len(result) == 2 { |
| ret, err := strconv.ParseInt(result[1], 10, 64) |
| if err != nil { |
| return 0, skerr.Wrapf(err, "parsing issue id '%s'", result[1]) |
| } |
| |
| return ret, nil |
| } |
| } |
| return 0, skerr.Fmt("unable to find Reviewed-on line") |
| } |
| |
| // Fix up a ChangeInfo object, received via the Gerrit API, to contain all of |
| // the fields it is expected to contain. Returns the ChangeInfo object for |
| // convenience. |
| func fixupChangeInfo(ci *ChangeInfo) *ChangeInfo { |
| // Set created, updated and submitted timestamps. Also set the committed flag. |
| ci.Created = parseTime(ci.CreatedString) |
| ci.Updated = parseTime(ci.UpdatedString) |
| if ci.SubmittedString != "" { |
| ci.Submitted = parseTime(ci.SubmittedString) |
| ci.Committed = true |
| } |
| // Make patchset objects with the revision IDs and created timestamps. |
| patchsets := make([]*Revision, 0, len(ci.Revisions)) |
| for id, r := range ci.Revisions { |
| // Fill in the missing fields. |
| r.ID = id |
| r.Created = parseTime(r.CreatedString) |
| patchsets = append(patchsets, r) |
| } |
| sort.Sort(revisionSlice(patchsets)) |
| ci.Patchsets = patchsets |
| return ci |
| } |
| |
| // GetIssueProperties returns a fully filled-in ChangeInfo object, as opposed to |
| // the partial data returned by Gerrit's search endpoint. |
| // If the given issue cannot be found ErrNotFound is returned as error. |
| func (g *Gerrit) GetIssueProperties(ctx context.Context, issue int64) (*ChangeInfo, error) { |
| url := fmt.Sprintf(URL_TMPL_CHANGE, issue) |
| fullIssue := &ChangeInfo{} |
| if err := g.get(ctx, url, fullIssue, ErrNotFound); err != nil { |
| // Pass ErrNotFound through unchanged so calling functions can check for it. |
| if err == ErrNotFound { |
| return nil, err |
| } |
| return nil, skerr.Fmt("Failed to load details for issue %d: %v", issue, err) |
| } |
| return fixupChangeInfo(fullIssue), nil |
| } |
| |
| // GetPatchsetIDs is a convenience function that returns the sorted list of patchset IDs. |
| func (c *ChangeInfo) GetPatchsetIDs() []int64 { |
| ret := make([]int64, len(c.Patchsets)) |
| for idx, patchSet := range c.Patchsets { |
| ret[idx] = patchSet.Number |
| } |
| return ret |
| } |
| |
| // GetPatch returns the formatted patch for one revision. Documentation is here: |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-patch |
| func (g *Gerrit) GetPatch(ctx context.Context, issue int64, revision string) (string, error) { |
| url := fmt.Sprintf("%s/changes/%d/revisions/%s/patch", g.url, issue, revision) |
| req, err := http.NewRequest(http.MethodGet, url, nil) |
| if err != nil { |
| return "", err |
| } |
| req = req.WithContext(ctx) |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return "", fmt.Errorf("Failed to GET %s: %s", url, err) |
| } |
| if resp.StatusCode == 404 { |
| return "", fmt.Errorf("Issue not found: %s", url) |
| } |
| if resp.StatusCode >= 400 { |
| return "", fmt.Errorf("Error retrieving %s: %d %s", url, resp.StatusCode, resp.Status) |
| } |
| defer util.Close(resp.Body) |
| body, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return "", fmt.Errorf("Could not read response body: %s", err) |
| } |
| |
| data, err := base64.StdEncoding.DecodeString(string(body)) |
| if err != nil { |
| return "", fmt.Errorf("Could not base64 decode response body: %s", err) |
| } |
| // Extract out only the patch. |
| tokens := strings.SplitN(string(data), "---", 2) |
| if len(tokens) != 2 { |
| return "", fmt.Errorf("Gerrit patch response was invalid: %s", string(data)) |
| } |
| patch := tokens[1] |
| return patch, nil |
| } |
| |
| // CommitInfo captures information about the commit of a revision (patchset) |
| // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info |
| type CommitInfo struct { |
| Commit string `json:"commit"` |
| Parents []*CommitInfo `json:"parents"` |
| } |
| |
| // GetCommit retrieves the commit that corresponds to the patch identified by issue and revision. |
| // It allows to retrieve the parent commit on which the given patchset is based on. |
| // See: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-commit |
| func (g *Gerrit) GetCommit(ctx context.Context, issue int64, revision string) (*CommitInfo, error) { |
| path := fmt.Sprintf("/changes/%d/revisions/%s/commit", issue, revision) |
| ret := &CommitInfo{} |
| err := g.get(ctx, path, ret, nil) |
| if err != nil { |
| return nil, err |
| } |
| return ret, nil |
| } |
| |
| type reviewer struct { |
| Reviewer string `json:"reviewer"` |
| } |
| |
| // setReview calls the Set Review endpoint of the Gerrit API to add messages and/or set labels for |
| // the latest patchset. |
| // API documentation: https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review |
| func (g *Gerrit) SetReview(ctx context.Context, issue *ChangeInfo, message string, labels map[string]int, reviewers []string) error { |
| postData := map[string]interface{}{ |
| "message": message, |
| "labels": labels, |
| } |
| |
| if len(reviewers) > 0 { |
| revs := make([]*reviewer, 0, len(reviewers)) |
| for _, r := range reviewers { |
| revs = append(revs, &reviewer{ |
| Reviewer: r, |
| }) |
| } |
| postData["reviewers"] = revs |
| } |
| latestPatchset := issue.Patchsets[len(issue.Patchsets)-1] |
| return g.postJson(ctx, fmt.Sprintf("/a/changes/%s/revisions/%s/review", issue.ChangeId, latestPatchset.ID), postData) |
| } |
| |
| // AddComment adds a message to the issue. |
| func (g *Gerrit) AddComment(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, map[string]int{}, nil) |
| } |
| |
| // Utility methods for interacting with the COMMITQUEUE_LABEL. |
| |
| func (g *Gerrit) SendToDryRun(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, g.cfg.SetDryRunLabels, nil) |
| } |
| |
| func (g *Gerrit) SendToCQ(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, g.cfg.SetCqLabels, nil) |
| } |
| |
| func (g *Gerrit) RemoveFromCQ(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, g.cfg.NoCqLabels, nil) |
| } |
| |
| // Utility methods for interacting with the CODEREVIEW_LABEL. |
| |
| func (g *Gerrit) Approve(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, map[string]int{CODEREVIEW_LABEL: CODEREVIEW_LABEL_APPROVE}, nil) |
| } |
| |
| func (g *Gerrit) NoScore(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, map[string]int{CODEREVIEW_LABEL: CODEREVIEW_LABEL_NONE}, nil) |
| } |
| |
| func (g *Gerrit) DisApprove(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, map[string]int{CODEREVIEW_LABEL: CODEREVIEW_LABEL_DISAPPROVE}, nil) |
| } |
| |
| func (g *Gerrit) SelfApprove(ctx context.Context, issue *ChangeInfo, message string) error { |
| return g.SetReview(ctx, issue, message, g.cfg.SelfApproveLabels, nil) |
| } |
| |
| // Abandon abandons the issue with the given message. |
| func (g *Gerrit) Abandon(ctx context.Context, issue *ChangeInfo, message string) error { |
| postData := map[string]interface{}{ |
| "message": message, |
| } |
| return g.postJson(ctx, fmt.Sprintf("/a/changes/%s/abandon", issue.ChangeId), postData) |
| } |
| |
| // get retrieves the given sub URL and populates 'rv' with the result. |
| // If notFoundError is not nil it will be returned if the requested item doesn't |
| // exist. |
| func (g *Gerrit) get(ctx context.Context, suburl string, rv interface{}, notFoundError error) error { |
| getURL := g.url + suburl |
| if g.useAuthenticatedGets { |
| getURL = g.url + "/a" + suburl |
| } |
| req, err := http.NewRequest("GET", getURL, nil) |
| if err != nil { |
| return err |
| } |
| req = req.WithContext(ctx) |
| |
| if g.useAuthenticatedGets { |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return err |
| } |
| } |
| |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return fmt.Errorf("Failed to GET %s: %s", getURL, err) |
| } |
| if resp.StatusCode == 404 { |
| if notFoundError != nil { |
| return notFoundError |
| } |
| return fmt.Errorf("Issue not found: %s", getURL) |
| } |
| defer util.Close(resp.Body) |
| bodyBytes, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return fmt.Errorf("Could not read response body: %s", err) |
| } |
| body := string(bodyBytes) |
| if resp.StatusCode >= 400 { |
| return fmt.Errorf("Error retrieving %s: %d %s; response:\n%s", getURL, resp.StatusCode, resp.Status, body) |
| } |
| |
| // Strip off the XSS protection chars. |
| parts := strings.SplitN(body, "\n", 2) |
| |
| if len(parts) != 2 { |
| return fmt.Errorf("Reponse invalid format; response:\n%s", body) |
| } |
| if err := json.Unmarshal([]byte(parts[1]), &rv); err != nil { |
| return fmt.Errorf("Failed to decode JSON: %s; response:\n%s", err, body) |
| } |
| return nil |
| } |
| |
| func (g *Gerrit) post(ctx context.Context, suburl string, b []byte) error { |
| req, err := http.NewRequest("POST", g.url+suburl, bytes.NewBuffer(b)) |
| if err != nil { |
| return err |
| } |
| req = req.WithContext(ctx) |
| |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return err |
| } |
| defer util.Close(resp.Body) |
| bodyBytes, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return fmt.Errorf("Could not read response body: %s", err) |
| } |
| body := string(bodyBytes) |
| if resp.StatusCode < 200 || resp.StatusCode > 204 { |
| return fmt.Errorf("Got status %s (%d); response:\n%s", resp.Status, resp.StatusCode, body) |
| } |
| return nil |
| } |
| |
| func (g *Gerrit) postJson(ctx context.Context, suburl string, postData interface{}) error { |
| b, err := json.Marshal(postData) |
| if err != nil { |
| return err |
| } |
| return g.post(ctx, suburl, b) |
| } |
| |
| func (g *Gerrit) put(ctx context.Context, suburl string, b []byte) error { |
| req, err := http.NewRequest("PUT", g.url+suburl, bytes.NewBuffer(b)) |
| if err != nil { |
| return err |
| } |
| req = req.WithContext(ctx) |
| |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return err |
| } |
| if resp.StatusCode < 200 || resp.StatusCode > 204 { |
| return fmt.Errorf("Got status %s (%d)", resp.Status, resp.StatusCode) |
| } |
| return nil |
| } |
| |
| func (g *Gerrit) putJson(ctx context.Context, suburl string, data interface{}) error { |
| b, err := json.Marshal(data) |
| if err != nil { |
| return err |
| } |
| return g.put(ctx, suburl, b) |
| } |
| |
| func (g *Gerrit) delete(ctx context.Context, suburl string) error { |
| req, err := http.NewRequest("DELETE", g.url+suburl, nil) |
| if err != nil { |
| return err |
| } |
| req = req.WithContext(ctx) |
| |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return err |
| } |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return err |
| } |
| defer util.Close(resp.Body) |
| respBytes, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return err |
| } |
| if resp.StatusCode < 200 || resp.StatusCode > 204 { |
| return fmt.Errorf("Got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes)) |
| } |
| return nil |
| } |
| |
| type changeListSortable []*ChangeInfo |
| |
| func (p changeListSortable) Len() int { return len(p) } |
| func (p changeListSortable) Less(i, j int) bool { return p[i].Created.Before(p[j].Created) } |
| func (p changeListSortable) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
| |
| type revisionSlice []*Revision |
| |
| func (r revisionSlice) Len() int { return len(r) } |
| func (r revisionSlice) Less(i, j int) bool { return r[i].Created.Before(r[j].Created) } |
| func (r revisionSlice) Swap(i, j int) { r[i], r[j] = r[j], r[i] } |
| |
| // SearchTerm is a wrapper for search terms to pass into the Search method. |
| type SearchTerm struct { |
| Key string |
| Value string |
| } |
| |
| // SearchOwner is a SearchTerm used for filtering by issue owner. |
| // API documentation is here: https://review.openstack.org/Documentation/user-search.html |
| func SearchOwner(name string) *SearchTerm { |
| return &SearchTerm{ |
| Key: "owner", |
| Value: name, |
| } |
| } |
| |
| func SearchCommit(commit string) *SearchTerm { |
| return &SearchTerm{ |
| Key: "commit", |
| Value: commit, |
| } |
| } |
| |
| func SearchStatus(status string) *SearchTerm { |
| return &SearchTerm{ |
| Key: "status", |
| Value: status, |
| } |
| } |
| |
| func SearchProject(project string) *SearchTerm { |
| return &SearchTerm{ |
| Key: "project", |
| Value: project, |
| } |
| } |
| |
| func SearchLabel(label, value string) *SearchTerm { |
| return &SearchTerm{ |
| Key: "label", |
| Value: fmt.Sprintf("%s=%s", label, value), |
| } |
| } |
| |
| // SearchModifiedAfter is a SearchTerm used for finding issues modified after |
| // a particular time.Time. |
| // API documentation is here: https://review.openstack.org/Documentation/user-search.html |
| func SearchModifiedAfter(after time.Time) *SearchTerm { |
| return &SearchTerm{ |
| Key: "after", |
| Value: "\"" + strings.Trim(strings.Split(after.UTC().String(), "+")[0], " ") + "\"", |
| } |
| } |
| |
| // queryString encodes query parameters in the key:val[+key:val...] format specified here: |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes |
| func queryString(terms []*SearchTerm) string { |
| q := []string{} |
| for _, t := range terms { |
| q = append(q, fmt.Sprintf("%s:%s", t.Key, t.Value)) |
| } |
| return strings.Join(q, " ") |
| } |
| |
| // Sets a topic on the Gerrit change with the provided hash. |
| func (g *Gerrit) SetTopic(ctx context.Context, topic string, changeNum int64) error { |
| putData := map[string]interface{}{ |
| "topic": topic, |
| } |
| b, err := json.Marshal(putData) |
| if err != nil { |
| return err |
| } |
| req, err := http.NewRequest("PUT", fmt.Sprintf("%s/a/changes/%d/topic", g.url, changeNum), bytes.NewBuffer(b)) |
| if err != nil { |
| return err |
| } |
| req = req.WithContext(ctx) |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return err |
| } |
| if resp.StatusCode != 200 { |
| return fmt.Errorf("Got status %s (%d)", resp.Status, resp.StatusCode) |
| } |
| return nil |
| } |
| |
| // GetDependencies returns a slice of all dependencies around the specified change. See |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-related-changes |
| func (g *Gerrit) GetDependencies(ctx context.Context, changeNum int64, revision int) ([]*RelatedChangeAndCommitInfo, error) { |
| data := RelatedChangesInfo{} |
| err := g.get(ctx, fmt.Sprintf("/changes/%d/revisions/%d/related", changeNum, revision), &data, nil) |
| if err != nil { |
| return nil, err |
| } |
| return data.Changes, nil |
| } |
| |
| // HasOpenDependency returns true if there is an active direct dependency of the specified change. |
| func (g *Gerrit) HasOpenDependency(ctx context.Context, changeNum int64, revision int) (bool, error) { |
| dependencies, err := g.GetDependencies(ctx, changeNum, revision) |
| if err != nil { |
| return false, err |
| } |
| // Find the target change num in the chain of dependencies. |
| targetChangeIdx := 0 |
| for idx, relatedChange := range dependencies { |
| if relatedChange.Issue == changeNum { |
| targetChangeIdx = idx |
| break |
| } |
| } |
| // See if the target change has an open dependency. |
| if len(dependencies) > targetChangeIdx+1 { |
| // The next change will be the direct dependency. |
| dependency := dependencies[targetChangeIdx+1] |
| if dependency.Status != CHANGE_STATUS_ABANDONED && dependency.Status != CHANGE_STATUS_MERGED { |
| // If the dependency is not closed then it is an active dependency. |
| return true, nil |
| } |
| } |
| return false, nil |
| } |
| |
| // Search returns a slice of Issues which fit the given criteria. |
| func (g *Gerrit) Search(ctx context.Context, limit int, terms ...*SearchTerm) ([]*ChangeInfo, error) { |
| var issues changeListSortable |
| for { |
| data := make([]*ChangeInfo, 0) |
| queryLimit := util.MinInt(limit-len(issues), MAX_GERRIT_LIMIT) |
| skip := len(issues) |
| |
| q := url.Values{} |
| q.Add("q", queryString(terms)) |
| q.Add("n", strconv.Itoa(queryLimit)) |
| q.Add("S", strconv.Itoa(skip)) |
| searchUrl := "/changes/?" + q.Encode() |
| err := g.get(ctx, searchUrl, &data, nil) |
| if err != nil { |
| return nil, fmt.Errorf("Gerrit search failed: %v", err) |
| } |
| var moreChanges bool |
| |
| for _, issue := range data { |
| // See if there are more changes available. |
| moreChanges = issue.MoreChanges |
| issues = append(issues, fixupChangeInfo(issue)) |
| } |
| if len(issues) >= limit || !moreChanges { |
| break |
| } |
| } |
| |
| sort.Sort(issues) |
| return issues, nil |
| } |
| |
| func (g *Gerrit) GetTrybotResults(ctx context.Context, issueID int64, patchsetID int64) ([]*buildbucketpb.Build, error) { |
| return g.BuildbucketClient.GetTrybotsForCL(ctx, issueID, patchsetID, g.url) |
| } |
| |
| // SetReadyForReview marks the change as ready for review (ie, not WIP). |
| func (g *Gerrit) SetReadyForReview(ctx context.Context, ci *ChangeInfo) error { |
| return g.post(ctx, fmt.Sprintf("/a/changes/%d/ready", ci.Issue), []byte("{}")) |
| } |
| |
| var revisionRegex = regexp.MustCompile("^[a-z0-9]+$") |
| |
| // Files returns the files that were modified, added or deleted in a revision. |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-files |
| func (g *Gerrit) Files(ctx context.Context, issue int64, patch string) (map[string]*FileInfo, error) { |
| if patch == "" { |
| patch = "current" |
| } |
| if !revisionRegex.MatchString(patch) { |
| return nil, fmt.Errorf("Invalid 'patch' value.") |
| } |
| url := fmt.Sprintf("/changes/%d/revisions/%s/files", issue, patch) |
| files := map[string]*FileInfo{} |
| if err := g.get(ctx, url, &files, ErrNotFound); err != nil { |
| return nil, fmt.Errorf("Failed to list files for issue %d: %v", issue, err) |
| } |
| return files, nil |
| } |
| |
| // GetFileNames returns the list of files for the given issue at the given patch. If |
| // patch is the empty string then the most recent patch is queried. |
| func (g *Gerrit) GetFileNames(ctx context.Context, issue int64, patch string) ([]string, error) { |
| files, err := g.Files(ctx, issue, patch) |
| if err != nil { |
| return nil, err |
| } |
| // We only need the filenames. |
| ret := []string{} |
| for filename := range files { |
| ret = append(ret, filename) |
| } |
| return ret, nil |
| } |
| |
| // IsBinaryPatch returns true if the patch contains any binary files. |
| func (g *Gerrit) IsBinaryPatch(ctx context.Context, issue int64, revision string) (bool, error) { |
| files, err := g.Files(ctx, issue, revision) |
| if err != nil { |
| return false, err |
| } |
| for _, fileInfo := range files { |
| if fileInfo.Binary { |
| return true, nil |
| } |
| } |
| return false, nil |
| } |
| |
| // Submit submits the Change. |
| func (g *Gerrit) Submit(ctx context.Context, ci *ChangeInfo) error { |
| return g.post(ctx, fmt.Sprintf("/a/changes/%d/submit", ci.Issue), []byte("{}")) |
| } |
| |
| // CodeReviewCache is an LRU cache for Gerrit Issues that polls in the background to determine if |
| // issues have been updated. If so it expells them from the cache to force a reload. |
| type CodeReviewCache struct { |
| cache *lru.Cache |
| gerritAPI *Gerrit |
| timeDelta time.Duration |
| mutex sync.Mutex |
| } |
| |
| // NewCodeReviewCache returns a new cache for the given API instance, poll interval and maximum cache size. |
| func NewCodeReviewCache(gerritAPI *Gerrit, pollInterval time.Duration, cacheSize int) *CodeReviewCache { |
| ret := &CodeReviewCache{ |
| cache: lru.New(cacheSize), |
| gerritAPI: gerritAPI, |
| timeDelta: pollInterval * 2, |
| } |
| |
| // Start the poller. |
| go util.Repeat(pollInterval, nil, ret.poll) |
| return ret |
| } |
| |
| // Add an issue to the cache. |
| func (c *CodeReviewCache) Add(key int64, value *ChangeInfo) { |
| c.mutex.Lock() |
| defer c.mutex.Unlock() |
| sklog.Infof("\nAdding %d", key) |
| c.cache.Add(key, value) |
| } |
| |
| // Retrieve an issue from the cache. |
| func (c *CodeReviewCache) Get(key int64) (*ChangeInfo, bool) { |
| sklog.Infof("\nGetting: %d", key) |
| c.mutex.Lock() |
| defer c.mutex.Unlock() |
| if val, ok := c.cache.Get(key); ok { |
| return val.(*ChangeInfo), true |
| } |
| return nil, false |
| } |
| |
| // Poll Gerrit for all issues that have changed in the recent past. |
| func (c *CodeReviewCache) poll() { |
| // Search for all keys that have changed in the last timeDelta duration. |
| issues, err := c.gerritAPI.Search(context.Background(), 10000, SearchModifiedAfter(time.Now().Add(-c.timeDelta))) |
| if err != nil { |
| sklog.Errorf("Error polling Gerrit: %s", err) |
| return |
| } |
| |
| c.mutex.Lock() |
| defer c.mutex.Unlock() |
| for _, issue := range issues { |
| sklog.Infof("\nRemoving: %d", issue.Issue) |
| c.cache.Remove(issue.Issue) |
| } |
| } |
| |
| // ContainsAny returns true if the provided ChangeInfo slice contains any |
| // change with the same issueID as id. |
| func ContainsAny(id int64, changes []*ChangeInfo) bool { |
| for _, c := range changes { |
| if id == c.Issue { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // Create a new Change in the given project, based on the given branch, and with |
| // the given subject line. |
| func (g *Gerrit) CreateChange(ctx context.Context, project, branch, subject, baseCommit string) (*ChangeInfo, error) { |
| c := struct { |
| Project string `json:"project"` |
| Subject string `json:"subject"` |
| Branch string `json:"branch"` |
| Topic string `json:"topic"` |
| Status string `json:"status"` |
| BaseCommit string `json:"base_commit"` |
| }{ |
| Project: project, |
| Branch: branch, |
| Subject: subject, |
| Status: "NEW", |
| BaseCommit: baseCommit, |
| } |
| b, err := json.Marshal(c) |
| if err != nil { |
| return nil, err |
| } |
| req, err := http.NewRequest("POST", g.url+"/a/changes/", bytes.NewBuffer(b)) |
| if err != nil { |
| return nil, err |
| } |
| req = req.WithContext(ctx) |
| |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer util.Close(resp.Body) |
| respBytes, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return nil, err |
| } |
| if resp.StatusCode != 201 { |
| return nil, fmt.Errorf("Got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes)) |
| } |
| var ci ChangeInfo |
| if err := json.NewDecoder(bytes.NewReader(respBytes[4:])).Decode(&ci); err != nil { |
| return nil, err |
| } |
| return fixupChangeInfo(&ci), nil |
| } |
| |
| // Modify the given file to have the given content. A ChangeEdit is created, if |
| // one is not already active. You must call PublishChangeEdit in order for the |
| // change to become a new patch set, otherwise it has no effect. |
| func (g *Gerrit) EditFile(ctx context.Context, ci *ChangeInfo, filepath, content string) error { |
| url := g.url + fmt.Sprintf("/a/changes/%s/edit/%s", ci.Id, url.QueryEscape(filepath)) |
| b := []byte(content) |
| req, err := http.NewRequest("PUT", url, bytes.NewReader(b)) |
| if err != nil { |
| return fmt.Errorf("Failed to create PUT request: %s", err) |
| } |
| req = req.WithContext(ctx) |
| |
| if err := gitauth.AddAuthenticationCookie(g.gitCookiesPath, req); err != nil { |
| return fmt.Errorf("Failed to add auth cookie: %s", err) |
| } |
| resp, err := g.client.Do(req) |
| if err != nil { |
| return fmt.Errorf("Failed to execute request: %s", err) |
| } |
| defer util.Close(resp.Body) |
| respBytes, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return fmt.Errorf("Failed to read response body: %s", err) |
| } |
| if resp.StatusCode < 200 || resp.StatusCode > 204 { |
| return fmt.Errorf("Got status %s (%d): %s", resp.Status, resp.StatusCode, string(respBytes)) |
| } |
| return nil |
| } |
| |
| // Move a given file. A ChangeEdit is created, if one is not already active. |
| // You must call PublishChangeEdit in order for the change to become a new patch |
| // set, otherwise it has no effect. |
| func (g *Gerrit) MoveFile(ctx context.Context, ci *ChangeInfo, oldPath, newPath string) error { |
| data := struct { |
| OldPath string `json:"old_path"` |
| NewPath string `json:"new_path"` |
| }{ |
| OldPath: oldPath, |
| NewPath: newPath, |
| } |
| return g.postJson(ctx, fmt.Sprintf("/a/changes/%s/edit", ci.Id), data) |
| } |
| |
| // Delete the given file. A ChangeEdit is created, if one is not already active. |
| // You must call PublishChangeEdit in order for the change to become a new patch |
| // set, otherwise it has no effect. |
| func (g *Gerrit) DeleteFile(ctx context.Context, ci *ChangeInfo, filepath string) error { |
| return g.delete(ctx, fmt.Sprintf("/a/changes/%s/edit/%s", ci.Id, url.QueryEscape(filepath))) |
| } |
| |
| // Set the commit message for the ChangeEdit. A ChangeEdit is created, if one is |
| // not already active. You must call PublishChangeEdit in order for the change |
| // to become a new patch set, otherwise it has no effect. |
| func (g *Gerrit) SetCommitMessage(ctx context.Context, ci *ChangeInfo, msg string) error { |
| m := struct { |
| Message string `json:"message"` |
| }{ |
| Message: msg, |
| } |
| url := fmt.Sprintf("/a/changes/%s/edit:message", ci.Id) |
| return g.putJson(ctx, url, m) |
| } |
| |
| // Publish the active ChangeEdit as a new patch set. |
| func (g *Gerrit) PublishChangeEdit(ctx context.Context, ci *ChangeInfo) error { |
| msg := struct { |
| Notify string `json:"notify,omitempty"` |
| }{ |
| Notify: "ALL", |
| } |
| url := fmt.Sprintf("/a/changes/%s/edit:publish", ci.Id) |
| return g.postJson(ctx, url, msg) |
| } |
| |
| // Delete the active ChangeEdit, restoring the state to the last patch set. |
| func (g *Gerrit) DeleteChangeEdit(ctx context.Context, ci *ChangeInfo) error { |
| return g.delete(ctx, fmt.Sprintf("/a/changes/%s/edit", ci.Id)) |
| } |
| |
| // Set the given label on the ChangeInfo. |
| func SetLabel(ci *ChangeInfo, key string, value int) { |
| labelEntry, ok := ci.Labels[key] |
| if !ok { |
| labelEntry = &LabelEntry{ |
| All: []*LabelDetail{}, |
| } |
| ci.Labels[key] = labelEntry |
| } |
| labelEntry.All = append(labelEntry.All, &LabelDetail{ |
| Value: value, |
| }) |
| } |
| |
| // Set the given labels on the ChangeInfo. |
| func SetLabels(ci *ChangeInfo, labels map[string]int) { |
| for key, value := range labels { |
| SetLabel(ci, key, value) |
| } |
| } |
| |
| // Unset the given label on the ChangeInfo. |
| func UnsetLabel(ci *ChangeInfo, key string, value int) { |
| labelEntry, ok := ci.Labels[key] |
| if !ok { |
| return |
| } |
| newEntries := make([]*LabelDetail, 0, len(labelEntry.All)) |
| for _, details := range labelEntry.All { |
| if details.Value != value { |
| newEntries = append(newEntries, details) |
| } |
| } |
| labelEntry.All = newEntries |
| } |
| |
| // Unset the given labels on the ChangeInfo. |
| func UnsetLabels(ci *ChangeInfo, labels map[string]int) { |
| for key, value := range labels { |
| UnsetLabel(ci, key, value) |
| } |
| } |