diff options
Diffstat (limited to 'third_party/gerrit-queue')
21 files changed, 1626 insertions, 0 deletions
diff --git a/third_party/gerrit-queue/.buildkite/build.sh b/third_party/gerrit-queue/.buildkite/build.sh new file mode 100755 index 000000000000..0a218c817ee5 --- /dev/null +++ b/third_party/gerrit-queue/.buildkite/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +export GOPATH=~/go +go generate +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -a -ldflags '-extldflags \"-static\"' -o gerrit-queue diff --git a/third_party/gerrit-queue/.buildkite/pipeline.yml b/third_party/gerrit-queue/.buildkite/pipeline.yml new file mode 100644 index 000000000000..0885cb9694ee --- /dev/null +++ b/third_party/gerrit-queue/.buildkite/pipeline.yml @@ -0,0 +1,13 @@ +steps: + - command: | + . /var/lib/buildkite-agent/.nix-profile/etc/profile.d/nix.sh + # produces a ./gerrit-queue + nix-shell --run ./.buildkite/build.sh + + mkdir -p out + mv ./gerrit-queue out/gerrit-queue-$(git describe --tags) + + label: "Build (linux/amd64)" + timeout: 30 + artifact_paths: + - "out/*" diff --git a/third_party/gerrit-queue/.envrc b/third_party/gerrit-queue/.envrc new file mode 100644 index 000000000000..90cf1bb145ab --- /dev/null +++ b/third_party/gerrit-queue/.envrc @@ -0,0 +1,17 @@ +# This configures [direnv](https://direnv.net/) if installed and enabled to +# automatically enter the nix-shell defined in `shell.nix`, +# either by using lorri if available, or nix-shell otherwise. + +if type lorri &>/dev/null; then + eval "$(lorri direnv)" +else + # fall back to using direnv's builtin nix support (blocking) + use nix +fi + +# enable go modules +export GO111MODULE=on +unset GOPATH + +# Load private overrides +[[ -f .envrc.private ]] && source_env .envrc.private diff --git a/third_party/gerrit-queue/.gitignore b/third_party/gerrit-queue/.gitignore new file mode 100644 index 000000000000..f2ec770e3c34 --- /dev/null +++ b/third_party/gerrit-queue/.gitignore @@ -0,0 +1,4 @@ +/.vscode +/statik +/.envrc.private +/gerrit-queue diff --git a/third_party/gerrit-queue/LICENSE b/third_party/gerrit-queue/LICENSE new file mode 100644 index 000000000000..261eeb9e9f8b --- /dev/null +++ b/third_party/gerrit-queue/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/gerrit-queue/README.md b/third_party/gerrit-queue/README.md new file mode 100644 index 000000000000..9ffb81b8d26d --- /dev/null +++ b/third_party/gerrit-queue/README.md @@ -0,0 +1,80 @@ +# gerrit-queue + +This daemon automatically rebases and submits changesets from a Gerrit +instance, ensuring they still pass CI. + +In a usual gerrit setup with a linear master history, different developers +await CI feedback on a rebased changeset, then one clicks submit, and +effectively makes everybody else rebase again. `gerrit-queue` is meant to +remove these races to master. + +Developers can set the `Autosubmit` label to `+1` on all changesets in a series, +and if all preconditions on are met ("submittable" in gerrit speech, this +usually means passing CI and passing Code Review), `gerrit-queue` takes care of +rebasing and submitting it to master + +## How it works +Gerrit only knows about Changesets (and some relations to other changesets), +but usually developers think in terms of multiple changesets. + +### Fetching changesets +`gerrit-queue` fetches all changesets from gerrit, and tries to identify these +chains of changesets. We call them `Series`. All changesets need to have strict +parent/child relationships to be detected (so if only half of the stack gets +rebased by the Gerrit Web interface, these are considered individual series. + +Series are sorted by the number of changesets in them. This ensures longer +series are merged faster, and less rebases are triggered. In the future, this +might be extended to other metrics. + +### Submitting changesets +The submitqueue has a Trigger() function, which gets periodically executed. + +It can keep a reference to one single serie across multiple runs. This is +necessary if it previously rebased one serie to current HEAD and needs to wait +some time until CI feedback is there. If it wouldn't keep that state, it would +pick another series (with +1 from CI) and trigger a rebase on that one, so +depending on CI run times and trigger intervals, if not keepig this information +it'd end up rebasing all unrebased changesets on the same HEAD, and then just +pick one, instead of waiting for the one to finish. + +The Trigger() function first instructs the gerrit client to fetch changesets +and assemble series. +If there is a `wipSerie` from a previous run, we check if it can still be found +in the newly assembled list of series (it still needs to contain the same +number of series. Commit IDs may differ, because the code doesn't reassemble a +`wipSerie` after scheduling a rebase. +If the `wipSerie` could be refreshed, we update the pointer with the newly +assembled series. If we couldn't find it, we drop it. + +Now, we enter the main for loop. The first half of the loop checks various +conditions of the current `wipSerie`, and if successful, does the submit +("Submit phase"), the second half will pick a suitable new `wipSerie`, and +potentially do a rebase ("Pick phase"). + +#### Submit phase +We check if there is an existing `wipSerie`. If there isn't, we immediately go to +the "pick" phase. + +The `wipSerie` still needs to be rebased on `HEAD` (otherwise, the submit queue +advanced outside of gerrit), and should not fail CI (logical merge conflict) - +otherwise we discard it, and continue with the picking phase. + +If the `wipSerie` still contains a changeset awaiting CI feedback, we `return` +from the `Trigger()` function (and go back to sleep). + +If the changeset is "submittable" in gerrit speech, and has the necessary +submit queue tag set, we submit it. + +#### Pick phase +The pick phase finds a new `wipSerie`. It'll first try to find one that already +is rebased on the current `HEAD` (so the loop can just continue, and the next +submit phase simply submit), and otherwise fall back to a not-yet-rebased +serie. Because the rebase mandates waiting for CI, the code `return`s the +`Trigger()` function, so it'll be called again after waiting some time. + +## Compile and Run +```sh +go generate +GERRIT_PASSWORD=mypassword go run main.go --url https://gerrit.mydomain.com --username myuser --project myproject +``` diff --git a/third_party/gerrit-queue/default.nix b/third_party/gerrit-queue/default.nix new file mode 100644 index 000000000000..468d5b9b54ac --- /dev/null +++ b/third_party/gerrit-queue/default.nix @@ -0,0 +1,20 @@ +{ pkgs, lib, ... }: + +pkgs.buildGoModule { + pname = "gerrit-queue"; + version = "master"; + vendorSha256 = "1bqllafvd4yy4cy6barpqhycxmhzcx3p5shpzhd8qwxwwg0clxs6"; + src = ./.; + + # gerrit-queue embeds static assets which need to be generated + nativeBuildInputs = [ pkgs.statik ]; + preBuild = '' + statik -f + ''; + + meta = with lib; { + description = "Gerrit submit bot"; + homepage = "https://github.com/tweag/gerrit-queue"; + license = licenses.asl20; + }; +} diff --git a/third_party/gerrit-queue/frontend/frontend.go b/third_party/gerrit-queue/frontend/frontend.go new file mode 100644 index 000000000000..f00bc414a1c7 --- /dev/null +++ b/third_party/gerrit-queue/frontend/frontend.go @@ -0,0 +1,115 @@ +package frontend + +import ( + "fmt" + "io/ioutil" + "net/http" + "encoding/json" + + "html/template" + + "github.com/rakyll/statik/fs" + + "github.com/apex/log" + + "github.com/tweag/gerrit-queue/gerrit" + "github.com/tweag/gerrit-queue/misc" + _ "github.com/tweag/gerrit-queue/statik" // register static assets + "github.com/tweag/gerrit-queue/submitqueue" +) + +//loadTemplate loads a list of templates, relative to the statikFS root, and a FuncMap, and returns a template object +func loadTemplate(templateNames []string, funcMap template.FuncMap) (*template.Template, error) { + if len(templateNames) == 0 { + return nil, fmt.Errorf("templateNames can't be empty") + } + tmpl := template.New(templateNames[0]).Funcs(funcMap) + statikFS, err := fs.New() + if err != nil { + return nil, err + } + + for _, templateName := range templateNames { + r, err := statikFS.Open("/" + templateName) + if err != nil { + return nil, err + } + defer r.Close() + contents, err := ioutil.ReadAll(r) + if err != nil { + return nil, err + } + tmpl, err = tmpl.Parse(string(contents)) + if err != nil { + return nil, err + } + } + + return tmpl, nil +} + +// MakeFrontend returns a http.Handler +func MakeFrontend(rotatingLogHandler *misc.RotatingLogHandler, gerritClient *gerrit.Client, runner *submitqueue.Runner) http.Handler { + projectName := gerritClient.GetProjectName() + branchName := gerritClient.GetBranchName() + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + var wipSerie *gerrit.Serie = nil + HEAD := "" + currentlyRunning := runner.IsCurrentlyRunning() + + // don't trigger operations requiring a lock + if !currentlyRunning { + wipSerie = runner.GetWIPSerie() + HEAD = gerritClient.GetHEAD() + } + + funcMap := template.FuncMap{ + "changesetURL": func(changeset *gerrit.Changeset) string { + return gerritClient.GetChangesetURL(changeset) + }, + "levelToClasses": func(level log.Level) string { + switch level { + case log.DebugLevel: + return "text-muted" + case log.InfoLevel: + return "text-info" + case log.WarnLevel: + return "text-warning" + case log.ErrorLevel: + return "text-danger" + case log.FatalLevel: + return "text-danger" + default: + return "text-white" + } + }, + "fieldsToJSON": func(fields log.Fields) string { + jsonData, _ := json.Marshal(fields) + return string(jsonData) + }, + } + + tmpl := template.Must(loadTemplate([]string{ + "index.tmpl.html", + "serie.tmpl.html", + "changeset.tmpl.html", + }, funcMap)) + + tmpl.ExecuteTemplate(w, "index.tmpl.html", map[string]interface{}{ + // Config + "projectName": projectName, + "branchName": branchName, + + // State + "currentlyRunning": currentlyRunning, + "wipSerie": wipSerie, + "HEAD": HEAD, + + // History + "memory": rotatingLogHandler, + }) + }) + return mux +} diff --git a/third_party/gerrit-queue/gerrit/changeset.go b/third_party/gerrit-queue/gerrit/changeset.go new file mode 100644 index 000000000000..f71032a567cb --- /dev/null +++ b/third_party/gerrit-queue/gerrit/changeset.go @@ -0,0 +1,117 @@ +package gerrit + +import ( + "bytes" + "fmt" + + goGerrit "github.com/andygrunwald/go-gerrit" + "github.com/apex/log" +) + +// Changeset represents a single changeset +// Relationships between different changesets are described in Series +type Changeset struct { + changeInfo *goGerrit.ChangeInfo + ChangeID string + Number int + Verified int + CodeReviewed int + Autosubmit int + Submittable bool + CommitID string + ParentCommitIDs []string + OwnerName string + Subject string +} + +// MakeChangeset creates a new Changeset object out of a goGerrit.ChangeInfo object +func MakeChangeset(changeInfo *goGerrit.ChangeInfo) *Changeset { + return &Changeset{ + changeInfo: changeInfo, + ChangeID: changeInfo.ChangeID, + Number: changeInfo.Number, + Verified: labelInfoToInt(changeInfo.Labels["Verified"]), + CodeReviewed: labelInfoToInt(changeInfo.Labels["Code-Review"]), + Autosubmit: labelInfoToInt(changeInfo.Labels["Autosubmit"]), + Submittable: changeInfo.Submittable, + CommitID: changeInfo.CurrentRevision, // yes, this IS the commit ID. + ParentCommitIDs: getParentCommitIDs(changeInfo), + OwnerName: changeInfo.Owner.Name, + Subject: changeInfo.Subject, + } +} + +// IsAutosubmit returns true if the changeset is intended to be +// automatically submitted by gerrit-queue. +// +// This is determined by the Change Owner setting +1 on the +// "Autosubmit" label. +func (c *Changeset) IsAutosubmit() bool { + return c.Autosubmit == 1 +} + +// IsVerified returns true if the changeset passed CI, +// that's when somebody left the Approved (+1) on the "Verified" label +func (c *Changeset) IsVerified() bool { + return c.Verified == 1 +} + +// IsCodeReviewed returns true if the changeset passed code review, +// that's when somebody left the Recommended (+2) on the "Code-Review" label +func (c *Changeset) IsCodeReviewed() bool { + return c.CodeReviewed == 2 +} + +func (c *Changeset) String() string { + var b bytes.Buffer + b.WriteString("Changeset") + b.WriteString(fmt.Sprintf("(commitID: %.7s, author: %s, subject: %s, submittable: %v)", + c.CommitID, c.OwnerName, c.Subject, c.Submittable)) + return b.String() +} + +// FilterChangesets filters a list of Changeset by a given filter function +func FilterChangesets(changesets []*Changeset, f func(*Changeset) bool) []*Changeset { + newChangesets := make([]*Changeset, 0) + for _, changeset := range changesets { + if f(changeset) { + newChangesets = append(newChangesets, changeset) + } else { + log.WithField("changeset", changeset.String()).Debug("dropped by filter") + } + } + return newChangesets +} + +// labelInfoToInt converts a goGerrit.LabelInfo to -2…+2 int +func labelInfoToInt(labelInfo goGerrit.LabelInfo) int { + if labelInfo.Recommended.AccountID != 0 { + return 2 + } + if labelInfo.Approved.AccountID != 0 { + return 1 + } + if labelInfo.Disliked.AccountID != 0 { + return -1 + } + if labelInfo.Rejected.AccountID != 0 { + return -2 + } + return 0 +} + +// getParentCommitIDs returns the parent commit IDs of the goGerrit.ChangeInfo +// There is usually only one parent commit ID, except for merge commits. +func getParentCommitIDs(changeInfo *goGerrit.ChangeInfo) []string { + // obtain the RevisionInfo object + revisionInfo := changeInfo.Revisions[changeInfo.CurrentRevision] + + // obtain the Commit object + commit := revisionInfo.Commit + + commitIDs := make([]string, len(commit.Parents)) + for i, commit := range commit.Parents { + commitIDs[i] = commit.Commit + } + return commitIDs +} diff --git a/third_party/gerrit-queue/gerrit/client.go b/third_party/gerrit-queue/gerrit/client.go new file mode 100644 index 000000000000..314f97281c7e --- /dev/null +++ b/third_party/gerrit-queue/gerrit/client.go @@ -0,0 +1,220 @@ +package gerrit + +import ( + "fmt" + + goGerrit "github.com/andygrunwald/go-gerrit" + "github.com/apex/log" + + "net/url" +) + +// passed to gerrit when retrieving changesets +var additionalFields = []string{ + "LABELS", + "CURRENT_REVISION", + "CURRENT_COMMIT", + "DETAILED_ACCOUNTS", + "SUBMITTABLE", +} + +// IClient defines the gerrit.Client interface +type IClient interface { + Refresh() error + GetHEAD() string + GetBaseURL() string + GetChangesetURL(changeset *Changeset) string + SubmitChangeset(changeset *Changeset) (*Changeset, error) + RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) + ChangesetIsRebasedOnHEAD(changeset *Changeset) bool + SerieIsRebasedOnHEAD(serie *Serie) bool + FilterSeries(filter func(s *Serie) bool) []*Serie + FindSerie(filter func(s *Serie) bool) *Serie +} + +var _ IClient = &Client{} + +// Client provides some ways to interact with a gerrit instance +type Client struct { + client *goGerrit.Client + logger *log.Logger + baseURL string + projectName string + branchName string + series []*Serie + head string +} + +// NewClient initializes a new gerrit client +func NewClient(logger *log.Logger, URL, username, password, projectName, branchName string) (*Client, error) { + urlParsed, err := url.Parse(URL) + if err != nil { + return nil, err + } + urlParsed.User = url.UserPassword(username, password) + + goGerritClient, err := goGerrit.NewClient(urlParsed.String(), nil) + if err != nil { + return nil, err + } + return &Client{ + client: goGerritClient, + baseURL: URL, + logger: logger, + projectName: projectName, + branchName: branchName, + }, nil +} + +// refreshHEAD queries the commit ID of the selected project and branch +func (c *Client) refreshHEAD() (string, error) { + branchInfo, _, err := c.client.Projects.GetBranch(c.projectName, c.branchName) + if err != nil { + return "", err + } + return branchInfo.Revision, nil +} + +// GetHEAD returns the internally stored HEAD +func (c *Client) GetHEAD() string { + return c.head +} + +// Refresh causes the client to refresh internal view of gerrit +func (c *Client) Refresh() error { + c.logger.Debug("refreshing from gerrit") + HEAD, err := c.refreshHEAD() + if err != nil { + return err + } + c.head = HEAD + + var queryString = fmt.Sprintf("status:open project:%s branch:%s", c.projectName, c.branchName) + c.logger.Debugf("fetching changesets: %s", queryString) + changesets, err := c.fetchChangesets(queryString) + if err != nil { + return err + } + + c.logger.Infof("assembling series…") + series, err := AssembleSeries(changesets, c.logger) + if err != nil { + return err + } + series = SortSeries(series) + c.series = series + return nil +} + +// fetchChangesets fetches a list of changesets matching a passed query string +func (c *Client) fetchChangesets(queryString string) (changesets []*Changeset, Error error) { + opt := &goGerrit.QueryChangeOptions{} + opt.Query = []string{ + queryString, + } + opt.AdditionalFields = additionalFields + changes, _, err := c.client.Changes.QueryChanges(opt) + if err != nil { + return nil, err + } + + changesets = make([]*Changeset, 0) + for _, change := range *changes { + changesets = append(changesets, MakeChangeset(&change)) + } + + return changesets, nil +} + +// fetchChangeset downloads an existing Changeset from gerrit, by its ID +// Gerrit's API is a bit sparse, and only returns what you explicitly ask it +// This is used to refresh an existing changeset with more data. +func (c *Client) fetchChangeset(changeID string) (*Changeset, error) { + opt := goGerrit.ChangeOptions{} + opt.AdditionalFields = []string{"LABELS", "DETAILED_ACCOUNTS"} + changeInfo, _, err := c.client.Changes.GetChange(changeID, &opt) + if err != nil { + return nil, err + } + return MakeChangeset(changeInfo), nil +} + +// SubmitChangeset submits a given changeset, and returns a changeset afterwards. +func (c *Client) SubmitChangeset(changeset *Changeset) (*Changeset, error) { + changeInfo, _, err := c.client.Changes.SubmitChange(changeset.ChangeID, &goGerrit.SubmitInput{}) + if err != nil { + return nil, err + } + c.head = changeInfo.CurrentRevision + return c.fetchChangeset(changeInfo.ChangeID) +} + +// RebaseChangeset rebases a given changeset on top of a given ref +func (c *Client) RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) { + changeInfo, _, err := c.client.Changes.RebaseChange(changeset.ChangeID, &goGerrit.RebaseInput{ + Base: ref, + }) + if err != nil { + return changeset, err + } + return c.fetchChangeset(changeInfo.ChangeID) +} + +// GetBaseURL returns the gerrit base URL +func (c *Client) GetBaseURL() string { + return c.baseURL +} + +// GetProjectName returns the configured gerrit project name +func (c *Client) GetProjectName() string { + return c.projectName +} + +// GetBranchName returns the configured gerrit branch name +func (c *Client) GetBranchName() string { + return c.branchName +} + +// GetChangesetURL returns the URL to view a given changeset +func (c *Client) GetChangesetURL(changeset *Changeset) string { + return fmt.Sprintf("%s/c/%s/+/%d", c.GetBaseURL(), c.projectName, changeset.Number) +} + +// ChangesetIsRebasedOnHEAD returns true if the changeset is rebased on the current HEAD +func (c *Client) ChangesetIsRebasedOnHEAD(changeset *Changeset) bool { + if len(changeset.ParentCommitIDs) != 1 { + return false + } + return changeset.ParentCommitIDs[0] == c.head +} + +// SerieIsRebasedOnHEAD returns true if the whole series is rebased on the current HEAD +// this is already the case if the first changeset in the series is rebased on the current HEAD +func (c *Client) SerieIsRebasedOnHEAD(serie *Serie) bool { + // an empty serie should not exist + if len(serie.ChangeSets) == 0 { + return false + } + return c.ChangesetIsRebasedOnHEAD(serie.ChangeSets[0]) +} + +// FilterSeries returns a subset of all Series, passing the given filter function +func (c *Client) FilterSeries(filter func(s *Serie) bool) []*Serie { + matchedSeries := []*Serie{} + for _, serie := range c.series { + if filter(serie) { + matchedSeries = append(matchedSeries, serie) + } + } + return matchedSeries +} + +// FindSerie returns the first serie that matches the filter, or nil if none was found +func (c *Client) FindSerie(filter func(s *Serie) bool) *Serie { + for _, serie := range c.series { + if filter(serie) { + return serie + } + } + return nil +} diff --git a/third_party/gerrit-queue/gerrit/serie.go b/third_party/gerrit-queue/gerrit/serie.go new file mode 100644 index 000000000000..788cf46f4ea6 --- /dev/null +++ b/third_party/gerrit-queue/gerrit/serie.go @@ -0,0 +1,112 @@ +package gerrit + +import ( + "fmt" + "strings" + + "github.com/apex/log" +) + +// Serie represents a list of successive changesets with an unbroken parent -> child relation, +// starting from the parent. +type Serie struct { + ChangeSets []*Changeset +} + +// GetParentCommitIDs returns the parent commit IDs +func (s *Serie) GetParentCommitIDs() ([]string, error) { + if len(s.ChangeSets) == 0 { + return nil, fmt.Errorf("Can't return parent on a serie with zero ChangeSets") + } + return s.ChangeSets[0].ParentCommitIDs, nil +} + +// GetLeafCommitID returns the commit id of the last commit in ChangeSets +func (s *Serie) GetLeafCommitID() (string, error) { + if len(s.ChangeSets) == 0 { + return "", fmt.Errorf("Can't return leaf on a serie with zero ChangeSets") + } + return s.ChangeSets[len(s.ChangeSets)-1].CommitID, nil +} + +// CheckIntegrity checks that the series contains a properly ordered and connected chain of commits +func (s *Serie) CheckIntegrity() error { + logger := log.WithField("serie", s) + // an empty serie is invalid + if len(s.ChangeSets) == 0 { + return fmt.Errorf("An empty serie is invalid") + } + + previousCommitID := "" + for i, changeset := range s.ChangeSets { + // we can't really check the parent of the first commit + // so skip verifying that one + logger.WithFields(log.Fields{ + "changeset": changeset.String(), + "previousCommitID": fmt.Sprintf("%.7s", previousCommitID), + }).Debug(" - verifying changeset") + + parentCommitIDs := changeset.ParentCommitIDs + if len(parentCommitIDs) == 0 { + return fmt.Errorf("Changesets without any parent are not supported") + } + // we don't check parents of the first changeset in a series + if i != 0 { + if len(parentCommitIDs) != 1 { + return fmt.Errorf("Merge commits in the middle of a series are not supported (only at the beginning)") + } + if parentCommitIDs[0] != previousCommitID { + return fmt.Errorf("changesets parent commit id doesn't match previous commit id") + } + } + // update previous commit id for the next loop iteration + previousCommitID = changeset.CommitID + } + return nil +} + +// FilterAllChangesets applies a filter function on all of the changesets in the series. +// returns true if it returns true for all changesets, false otherwise +func (s *Serie) FilterAllChangesets(f func(c *Changeset) bool) bool { + for _, changeset := range s.ChangeSets { + if f(changeset) == false { + return false + } + } + return true +} + +func (s *Serie) String() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Serie[%d]", len(s.ChangeSets))) + if len(s.ChangeSets) == 0 { + sb.WriteString("()\n") + return sb.String() + } + parentCommitIDs, err := s.GetParentCommitIDs() + if err == nil { + if len(parentCommitIDs) == 1 { + sb.WriteString(fmt.Sprintf("(parent: %.7s)", parentCommitIDs[0])) + } else { + sb.WriteString("(merge: ") + + for i, parentCommitID := range parentCommitIDs { + sb.WriteString(fmt.Sprintf("%.7s", parentCommitID)) + if i < len(parentCommitIDs) { + sb.WriteString(", ") + } + } + + sb.WriteString(")") + + } + } + sb.WriteString(fmt.Sprintf("(%.7s..%.7s)", + s.ChangeSets[0].CommitID, + s.ChangeSets[len(s.ChangeSets)-1].CommitID)) + return sb.String() +} + +func shortCommitID(commitID string) string { + return commitID[:6] +} diff --git a/third_party/gerrit-queue/gerrit/series.go b/third_party/gerrit-queue/gerrit/series.go new file mode 100644 index 000000000000..295193ee9503 --- /dev/null +++ b/third_party/gerrit-queue/gerrit/series.go @@ -0,0 +1,126 @@ +package gerrit + +import ( + "sort" + + "github.com/apex/log" +) + +// AssembleSeries consumes a list of `Changeset`, and groups them together to series +// +// We initially put every Changeset in its own Serie +// +// As we have no control over the order of the passed changesets, +// we maintain a lookup table, mapLeafToSerie, +// which allows to lookup a serie by its leaf commit id +// We concat series in a fixpoint approach +// because both appending and prepending is much more complex. +// Concatenation moves changesets of the later changeset in the previous one +// in a cleanup phase, we remove orphaned series (those without any changesets inside) +// afterwards, we do an integrity check, just to be on the safe side. +func AssembleSeries(changesets []*Changeset, logger *log.Logger) ([]*Serie, error) { + series := make([]*Serie, 0) + mapLeafToSerie := make(map[string]*Serie, 0) + + for _, changeset := range changesets { + l := logger.WithField("changeset", changeset.String()) + + l.Debug("creating initial serie") + serie := &Serie{ + ChangeSets: []*Changeset{changeset}, + } + series = append(series, serie) + mapLeafToSerie[changeset.CommitID] = serie + } + + // Combine series using a fixpoint approach, with a max iteration count. + logger.Debug("glueing together phase") + for i := 1; i < 100; i++ { + didUpdate := false + logger.Debugf("at iteration %d", i) + for j, serie := range series { + l := logger.WithFields(log.Fields{ + "i": i, + "j": j, + "serie": serie.String(), + }) + parentCommitIDs, err := serie.GetParentCommitIDs() + if err != nil { + return series, err + } + if len(parentCommitIDs) != 1 { + // We can't append merge commits to other series + l.Infof("No single parent, skipping.") + continue + } + parentCommitID := parentCommitIDs[0] + l.Debug("Looking for a predecessor.") + // if there's another serie that has this parent as a leaf, glue together + if otherSerie, ok := mapLeafToSerie[parentCommitID]; ok { + if otherSerie == serie { + continue + } + l = l.WithField("otherSerie", otherSerie) + + myLeafCommitID, err := serie.GetLeafCommitID() + if err != nil { + return series, err + } + + // append our changesets to the other serie + l.Debug("Splicing together.") + otherSerie.ChangeSets = append(otherSerie.ChangeSets, serie.ChangeSets...) + + delete(mapLeafToSerie, parentCommitID) + mapLeafToSerie[myLeafCommitID] = otherSerie + + // orphan our serie + serie.ChangeSets = []*Changeset{} + // remove the orphaned serie from the lookup table + delete(mapLeafToSerie, myLeafCommitID) + + didUpdate = true + } else { + l.Debug("Not found.") + } + } + series = removeOrphanedSeries(series) + if !didUpdate { + logger.Infof("converged after %d iterations", i) + break + } + } + + // Check integrity, just to be on the safe side. + for _, serie := range series { + l := logger.WithField("serie", serie.String()) + l.Debugf("checking integrity") + err := serie.CheckIntegrity() + if err != nil { + l.Errorf("checking integrity failed: %s", err) + } + } + return series, nil +} + +// removeOrphanedSeries removes all empty series (that contain zero changesets) +func removeOrphanedSeries(series []*Serie) []*Serie { + newSeries := []*Serie{} + for _, serie := range series { + if len(serie.ChangeSets) != 0 { + newSeries = append(newSeries, serie) + } + } + return newSeries +} + +// SortSeries sorts a list of series by the number of changesets in each serie, descending +func SortSeries(series []*Serie) []*Serie { + newSeries := make([]*Serie, len(series)) + copy(newSeries, series) + sort.Slice(newSeries, func(i, j int) bool { + // the weight depends on the amount of changesets series changeset size + return len(series[i].ChangeSets) > len(series[j].ChangeSets) + }) + return newSeries +} diff --git a/third_party/gerrit-queue/go.mod b/third_party/gerrit-queue/go.mod new file mode 100644 index 000000000000..0360ad21b26c --- /dev/null +++ b/third_party/gerrit-queue/go.mod @@ -0,0 +1,11 @@ +module github.com/tweag/gerrit-queue + +go 1.12 + +require ( + github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 + github.com/apex/log v1.1.1 + github.com/google/go-querystring v1.0.0 // indirect + github.com/rakyll/statik v0.1.6 + github.com/urfave/cli v1.22.1 +) diff --git a/third_party/gerrit-queue/go.sum b/third_party/gerrit-queue/go.sum new file mode 100644 index 000000000000..d4a50d487399 --- /dev/null +++ b/third_party/gerrit-queue/go.sum @@ -0,0 +1,71 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 h1:9PvNa6zH6gOW4VVfbAx5rjDLpxunG+RSaXQB+8TEv4w= +github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8/go.mod h1:0iuRQp6WJ44ts+iihy5E/WlPqfg5RNeQxOmzRkxCdtk= +github.com/apex/log v1.1.1 h1:BwhRZ0qbjYtTob0I+2M+smavV0kOC8XgcnGZcyL9liA= +github.com/apex/log v1.1.1/go.mod h1:Ls949n1HFtXfbDcjiTTFQqkVUrte0puoIBfO3SVgwOA= +github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= +github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= +github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rakyll/statik v0.1.6 h1:uICcfUXpgqtw2VopbIncslhAmE5hwc4g20TEyEENBNs= +github.com/rakyll/statik v0.1.6/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs= +github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= +github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= +github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= +github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= +github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/third_party/gerrit-queue/main.go b/third_party/gerrit-queue/main.go new file mode 100644 index 000000000000..d8577dad507e --- /dev/null +++ b/third_party/gerrit-queue/main.go @@ -0,0 +1,139 @@ +//go:generate statik -f + +package main + +import ( + "os" + "time" + + "net/http" + + "github.com/tweag/gerrit-queue/frontend" + "github.com/tweag/gerrit-queue/gerrit" + "github.com/tweag/gerrit-queue/misc" + "github.com/tweag/gerrit-queue/submitqueue" + + "github.com/urfave/cli" + + "github.com/apex/log" + "github.com/apex/log/handlers/multi" + "github.com/apex/log/handlers/text" +) + +func main() { + var URL, username, password, projectName, branchName string + var fetchOnly bool + var triggerInterval int + + app := cli.NewApp() + app.Name = "gerrit-queue" + + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "url", + Usage: "URL to the gerrit instance", + EnvVar: "GERRIT_URL", + Destination: &URL, + Required: true, + }, + cli.StringFlag{ + Name: "username", + Usage: "Username to use to login to gerrit", + EnvVar: "GERRIT_USERNAME", + Destination: &username, + Required: true, + }, + cli.StringFlag{ + Name: "password", + Usage: "Password to use to login to gerrit", + EnvVar: "GERRIT_PASSWORD", + Destination: &password, + Required: true, + }, + cli.StringFlag{ + Name: "project", + Usage: "Gerrit project name to run the submit queue for", + EnvVar: "GERRIT_PROJECT", + Destination: &projectName, + Required: true, + }, + cli.StringFlag{ + Name: "branch", + Usage: "Destination branch", + EnvVar: "GERRIT_BRANCH", + Destination: &branchName, + Value: "master", + }, + cli.IntFlag{ + Name: "trigger-interval", + Usage: "How often we should trigger ourselves (interval in seconds)", + EnvVar: "SUBMIT_QUEUE_TRIGGER_INTERVAL", + Destination: &triggerInterval, + Value: 600, + }, + cli.BoolFlag{ + Name: "fetch-only", + Usage: "Only fetch changes and assemble queue, but don't actually write", + EnvVar: "SUBMIT_QUEUE_FETCH_ONLY", + Destination: &fetchOnly, + }, + } + + rotatingLogHandler := misc.NewRotatingLogHandler(10000) + l := &log.Logger{ + Handler: multi.New( + text.New(os.Stderr), + rotatingLogHandler, + ), + Level: log.DebugLevel, + } + + app.Action = func(c *cli.Context) error { + gerrit, err := gerrit.NewClient(l, URL, username, password, projectName, branchName) + if err != nil { + return err + } + log.Infof("Successfully connected to gerrit at %s", URL) + + runner := submitqueue.NewRunner(l, gerrit) + + handler := frontend.MakeFrontend(rotatingLogHandler, gerrit, runner) + + // fetch only on first run + err = runner.Trigger(fetchOnly) + if err != nil { + log.Error(err.Error()) + } + + // ticker + go func() { + for { + time.Sleep(time.Duration(triggerInterval) * time.Second) + err = runner.Trigger(fetchOnly) + if err != nil { + log.Error(err.Error()) + } + } + }() + + server := http.Server{ + Addr: ":8080", + Handler: handler, + } + + server.ListenAndServe() + if err != nil { + log.Fatalf(err.Error()) + } + + return nil + } + + err := app.Run(os.Args) + if err != nil { + log.Fatal(err.Error()) + } + + // TODOS: + // - handle event log, either by accepting webhooks, or by streaming events? +} diff --git a/third_party/gerrit-queue/misc/rotatingloghandler.go b/third_party/gerrit-queue/misc/rotatingloghandler.go new file mode 100644 index 000000000000..3d4c5f3a837a --- /dev/null +++ b/third_party/gerrit-queue/misc/rotatingloghandler.go @@ -0,0 +1,34 @@ +package misc + +import ( + "sync" + + "github.com/apex/log" +) + +// RotatingLogHandler implementation. +type RotatingLogHandler struct { + mu sync.Mutex + Entries []*log.Entry + maxEntries int +} + +// NewRotatingLogHandler creates a new rotating log handler +func NewRotatingLogHandler(maxEntries int) *RotatingLogHandler { + return &RotatingLogHandler{ + maxEntries: maxEntries, + } +} + +// HandleLog implements log.Handler. +func (h *RotatingLogHandler) HandleLog(e *log.Entry) error { + h.mu.Lock() + defer h.mu.Unlock() + // drop tail if we have more entries than maxEntries + if len(h.Entries) > h.maxEntries { + h.Entries = append([]*log.Entry{e}, h.Entries[:(h.maxEntries-2)]...) + } else { + h.Entries = append([]*log.Entry{e}, h.Entries...) + } + return nil +} diff --git a/third_party/gerrit-queue/public/changeset.tmpl.html b/third_party/gerrit-queue/public/changeset.tmpl.html new file mode 100644 index 000000000000..5d3997885c73 --- /dev/null +++ b/third_party/gerrit-queue/public/changeset.tmpl.html @@ -0,0 +1,15 @@ +{{ define "changeset" }} +<tr> + <td>{{ .OwnerName }}</td> + <td> + <strong>{{ .Subject }}</strong> (<a href="{{ changesetURL . }}" target="_blank">#{{ .Number }}</a>)<br /> + <small><code>{{ .CommitID }}</code></small> + </td> + <td> + <span> + {{ if .IsVerified }}<span class="badge badge-success badge-pill">+1 (CI)</span>{{ end }} + {{ if .IsCodeReviewed }}<span class="badge badge-info badge-pill">+2 (CR)</span>{{ end }} + </span> + </td> +</tr> +{{ end }} \ No newline at end of file diff --git a/third_party/gerrit-queue/public/index.tmpl.html b/third_party/gerrit-queue/public/index.tmpl.html new file mode 100644 index 000000000000..e04c0a349dfc --- /dev/null +++ b/third_party/gerrit-queue/public/index.tmpl.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> +<head> + <title>Gerrit Submit Queue</title> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.js" integrity="sha256-WpOohJOqMqqyKL9FccASB9O0KwACQJpFTUBLTYOVvVU=" crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha256-CjSoeELFOcH0/uxWu6mC/Vlrc1AARqbm/jiiImDGV3s=" crossorigin="anonymous"></script> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha256-YLGeXaapI0/5IgZopewRJcFXomhRMlYYjugPLSyNjTY=" crossorigin="anonymous" /> +</head> +<body> + <nav class="navbar sticky-top navbar-expand-sm navbar-dark bg-dark"> + <div class="container"> + <a class="navbar-brand" href="#">Gerrit Submit Queue</a> + <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav mr-auto"> + <li class="nav-item"> + <a class="nav-link" href="#region-info">Info</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="#region-wipserie">WIP Serie</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="#region-log">Log</a> + </li> + </ul> + </div> + </div> + </nav> + <div class="container"> + <h2 id="region-info">Info</h2> + <table class="table"> + <tbody> + <tr> + <th scope="row">Project Name:</th> + <td>{{ .projectName }}</td> + </tr> + <tr> + <th scope="row">Branch Name:</th> + <td>{{ .branchName }}</td> + </tr> + <tr> + <th scope="row">Currently running:</th> + <td> + {{ if .currentlyRunning }}yes{{ else }}no{{ end }} + </td> + </tr> + <tr> + <th scope="row">HEAD:</th> + <td> + {{ if .HEAD }}{{ .HEAD }}{{ else }}-{{ end }} + </td> + </tr> + </tbody> + </table> + + <h2 id="region-wipserie">WIP Serie</h2> + {{ if .wipSerie }} + {{ block "serie" .wipSerie }}{{ end }} + {{ else }} + - + {{ end }} + + <h2 id="region-log">Log</h2> + {{ range $entry := .memory.Entries }} + <div class="d-flex flex-row bg-dark {{ levelToClasses $entry.Level }} text-monospace"> + <div class="p-2"><small>{{ $entry.Timestamp.Format "2006-01-02 15:04:05 UTC"}}</small></div> + <div class="p-2 flex-grow-1"><small><strong>{{ $entry.Message }}</strong></small></div> + </div> + <div class="bg-dark {{ levelToClasses $entry.Level }} text-monospace text-break" style="padding-left: 4rem"> + <small>{{ fieldsToJSON $entry.Fields }}</small> + </div> + {{ end }} +</body> +</html> diff --git a/third_party/gerrit-queue/public/serie.tmpl.html b/third_party/gerrit-queue/public/serie.tmpl.html new file mode 100644 index 000000000000..60f0c18113d7 --- /dev/null +++ b/third_party/gerrit-queue/public/serie.tmpl.html @@ -0,0 +1,19 @@ +{{ define "serie" }} +<table class="table table-sm table-hover"> +<thead class="thead-light"> + <tr> + <th scope="col">Owner</th> + <th scope="col">Changeset</th> + <th scope="col">Flags</th> + </tr> +</thead> +<tbody> + <tr> + <td colspan="3" class="table-success">Serie with {{ len .ChangeSets }} changes</td> + </tr> + {{ range $changeset := .ChangeSets }} + {{ block "changeset" $changeset }}{{ end }} + {{ end }} +</tbody> +</table> +{{ end }} \ No newline at end of file diff --git a/third_party/gerrit-queue/shell.nix b/third_party/gerrit-queue/shell.nix new file mode 100644 index 000000000000..7a2d65d59681 --- /dev/null +++ b/third_party/gerrit-queue/shell.nix @@ -0,0 +1,12 @@ +let + pkgs = (import (builtins.fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/5de728659b412bcf7d18316a4b71d9a6e447f460.tar.gz"; + sha256 = "1bdykda8k8gl2vcp36g27xf3437ig098yrhjp0hclv7sn6dp2w1l"; + })) {}; +in + pkgs.mkShell { + buildInputs = [ + pkgs.go_1_12 + pkgs.statik + ]; + } diff --git a/third_party/gerrit-queue/submitqueue/runner.go b/third_party/gerrit-queue/submitqueue/runner.go new file mode 100644 index 000000000000..5ac4b04db0fd --- /dev/null +++ b/third_party/gerrit-queue/submitqueue/runner.go @@ -0,0 +1,220 @@ +package submitqueue + +import ( + "fmt" + "sync" + + "github.com/apex/log" + + "github.com/tweag/gerrit-queue/gerrit" +) + +// Runner is a struct existing across the lifetime of a single run of the submit queue +// it contains a mutex to avoid being run multiple times. +// In fact, it even cancels runs while another one is still in progress. +// It contains a Gerrit object facilitating access, a log object, the configured submit queue tag +// and a `wipSerie` (only populated if waiting for a rebase) +type Runner struct { + mut sync.Mutex + currentlyRunning bool + wipSerie *gerrit.Serie + logger *log.Logger + gerrit *gerrit.Client +} + +// NewRunner creates a new Runner struct +func NewRunner(logger *log.Logger, gerrit *gerrit.Client) *Runner { + return &Runner{ + logger: logger, + gerrit: gerrit, + } +} + +// isAutoSubmittable determines if something could be autosubmitted, potentially requiring a rebase +// for this, it needs to: +// * have the "Autosubmit" label set to +1 +// * have gerrit's 'submittable' field set to true +// it doesn't check if the series is rebased on HEAD +func (r *Runner) isAutoSubmittable(s *gerrit.Serie) bool { + for _, c := range s.ChangeSets { + if c.Submittable != true || !c.IsAutosubmit() { + return false + } + } + return true +} + +// IsCurrentlyRunning returns true if the runner is currently running +func (r *Runner) IsCurrentlyRunning() bool { + return r.currentlyRunning +} + +// GetWIPSerie returns the current wipSerie, if any, nil otherwiese +// Acquires a lock, so check with IsCurrentlyRunning first +func (r *Runner) GetWIPSerie() *gerrit.Serie { + r.mut.Lock() + defer func() { + r.mut.Unlock() + }() + return r.wipSerie +} + +// Trigger gets triggered periodically +func (r *Runner) Trigger(fetchOnly bool) error { + // TODO: If CI fails, remove the auto-submit labels => rules.pl + // Only one trigger can run at the same time + r.mut.Lock() + if r.currentlyRunning { + return fmt.Errorf("Already running, skipping") + } + r.currentlyRunning = true + r.mut.Unlock() + defer func() { + r.mut.Lock() + r.currentlyRunning = false + r.mut.Unlock() + }() + + // Prepare the work by creating a local cache of gerrit state + err := r.gerrit.Refresh() + if err != nil { + return err + } + + // early return if we only want to fetch + if fetchOnly { + return nil + } + + if r.wipSerie != nil { + // refresh wipSerie with how it looks like in gerrit now + wipSerie := r.gerrit.FindSerie(func(s *gerrit.Serie) bool { + // the new wipSerie needs to have the same number of changesets + if len(r.wipSerie.ChangeSets) != len(s.ChangeSets) { + return false + } + // … and the same ChangeIDs. + for idx, c := range s.ChangeSets { + if r.wipSerie.ChangeSets[idx].ChangeID != c.ChangeID { + return false + } + } + return true + }) + if wipSerie == nil { + r.logger.WithField("wipSerie", r.wipSerie).Warn("wipSerie has disappeared") + r.wipSerie = nil + } else { + r.wipSerie = wipSerie + } + } + + for { + // initialize logger + r.logger.Info("Running") + if r.wipSerie != nil { + // if we have a wipSerie + l := r.logger.WithField("wipSerie", r.wipSerie) + l.Info("Checking wipSerie") + + // discard wipSerie not rebased on HEAD + // we rebase them at the end of the loop, so this means master advanced without going through the submit queue + if !r.gerrit.SerieIsRebasedOnHEAD(r.wipSerie) { + l.Warnf("HEAD has moved to {} while still waiting for wipSerie, discarding it", r.gerrit.GetHEAD()) + r.wipSerie = nil + continue + } + + // we now need to check CI feedback: + // wipSerie might have failed CI in the meantime + for _, c := range r.wipSerie.ChangeSets { + if c == nil { + l.Error("BUG: changeset is nil") + continue + } + if c.Verified < 0 { + l.WithField("failingChangeset", c).Warnf("wipSerie failed CI in the meantime, discarding.") + r.wipSerie = nil + continue + } + } + + // it might still be waiting for CI + for _, c := range r.wipSerie.ChangeSets { + if c == nil { + l.Error("BUG: changeset is nil") + continue + } + if c.Verified == 0 { + l.WithField("pendingChangeset", c).Warnf("still waiting for CI feedback in wipSerie, going back to sleep.") + // break the loop, take a look at it at the next trigger. + return nil + } + } + + // it might be autosubmittable + if r.isAutoSubmittable(r.wipSerie) { + l.Infof("submitting wipSerie") + // if the WIP changeset is ready (auto submittable and rebased on HEAD), submit + for _, changeset := range r.wipSerie.ChangeSets { + _, err := r.gerrit.SubmitChangeset(changeset) + if err != nil { + l.WithField("changeset", changeset).Error("error submitting changeset") + r.wipSerie = nil + return err + } + } + r.wipSerie = nil + } else { + l.Error("BUG: wipSerie is not autosubmittable") + r.wipSerie = nil + } + } + + r.logger.Info("Looking for series ready to submit") + // Find serie, that: + // * has the auto-submit label + // * has +2 review + // * has +1 CI + // * is rebased on master + serie := r.gerrit.FindSerie(func(s *gerrit.Serie) bool { + return r.isAutoSubmittable(s) && s.ChangeSets[0].ParentCommitIDs[0] == r.gerrit.GetHEAD() + }) + if serie != nil { + r.logger.WithField("serie", serie).Info("Found serie to submit without necessary rebase") + r.wipSerie = serie + continue + } + + // Find serie, that: + // * has the auto-submit label + // * has +2 review + // * has +1 CI + // * is NOT rebased on master + serie = r.gerrit.FindSerie(r.isAutoSubmittable) + if serie == nil { + r.logger.Info("no more submittable series found, going back to sleep.") + break + } + + l := r.logger.WithField("serie", serie) + l.Info("found serie, which needs a rebase") + // TODO: move into Client.RebaseSeries function + head := r.gerrit.GetHEAD() + for _, changeset := range serie.ChangeSets { + changeset, err := r.gerrit.RebaseChangeset(changeset, head) + if err != nil { + l.Error(err.Error()) + return err + } + head = changeset.CommitID + } + // we don't need to care about updating the rebased changesets or getting the updated HEAD, + // as we'll refetch it on the beginning of the next trigger anyways + r.wipSerie = serie + break + } + + r.logger.Info("Run complete") + return nil +} |