├── .buildkite ├── build.sh └── pipeline.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── frontend ├── frontend.go └── templates │ ├── chain.tmpl.html │ ├── changeset.tmpl.html │ └── index.tmpl.html ├── gerrit ├── chain.go ├── chains.go ├── changeset.go ├── changeset_test.go └── client.go ├── go.mod ├── go.sum ├── main.go ├── misc └── rotatingloghandler.go └── submitqueue └── runner.go /.buildkite/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export GOPATH=~/go 3 | go generate 4 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -a -ldflags '-extldflags \"-static\"' -o gerrit-queue 5 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - command: | 3 | . /var/lib/buildkite-agent/.nix-profile/etc/profile.d/nix.sh 4 | # produces a ./gerrit-queue 5 | nix-shell --run ./.buildkite/build.sh 6 | 7 | mkdir -p out 8 | mv ./gerrit-queue out/gerrit-queue-$(git describe --tags) 9 | 10 | label: "Build (linux/amd64)" 11 | timeout: 30 12 | artifact_paths: 13 | - "out/*" 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-20.04 12 | strategy: 13 | matrix: 14 | go: ['1.16', '1.17', '1.18', '1.19'] 15 | name: Build (Go ${{ matrix.go }}) 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-go@v3 19 | with: 20 | go-version: ${{ matrix.go }} 21 | - name: go test 22 | run: go test -v ./... 23 | - name: go build 24 | run: go build . 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /statik 3 | /.envrc.private 4 | /gerrit-queue 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gerrit-queue 2 | 3 | This daemon automatically rebases and submits changesets from a Gerrit 4 | instance, ensuring they still pass CI. 5 | 6 | In a usual gerrit setup with a linear master history, different developers 7 | await CI feedback on a rebased changeset, then one clicks submit, and 8 | effectively makes everybody else rebase again. `gerrit-queue` is meant to 9 | remove these races to master. 10 | 11 | Developers can set the `Autosubmit` customized label to `+1` on all changesets 12 | in a chain, and if all preconditions on are met ("submittable" in gerrit 13 | speech, this usually means passing CI and passing Code Review), 14 | `gerrit-queue` takes care of rebasing and submitting it to master. 15 | 16 | Refer to the [Customized Label Gerrit docs](https://gerrit-review.googlesource.com/Documentation/config-labels.html#label_custom) 17 | on how to create the `Autosubmit` label and configure permissions, but 18 | something like the following in your projects `project.config` should suffice: 19 | 20 | ``` 21 | [access "refs/*"] 22 | # […] 23 | # 24 | # Set exclusive because only the change owner should be able to do this: 25 | exclusiveGroupPermissions = label-Autosubmit 26 | label-Autosubmit = +0..+1 group Change Owner 27 | [label "Autosubmit"] 28 | function = NoOp 29 | value = 0 Submit manually 30 | value = +1 Submit automatically 31 | allowPostSubmit = false 32 | copyAnyScore = true 33 | defaultValue = 0 34 | ``` 35 | 36 | Note that if a setup uses `rules.pl`, the label will not be rendered unless it 37 | is configured as `may(_)` in the rules. 38 | 39 | See [TVL CL 4241](https://cl.tvl.fyi/c/depot/+/4241) for an example. 40 | 41 | ## How it works 42 | Gerrit only knows about Changesets (and some relations to other changesets), 43 | but usually developers think in terms of multiple changesets. 44 | 45 | ### Fetching changesets 46 | `gerrit-queue` fetches all changesets from gerrit, and tries to identify these 47 | chains of changesets. 48 | All changesets need to have strict parent/child relationships to be detected. 49 | (This means, if a user manually rebases half of a chain through the Gerrit Web 50 | Interface, these will be considered as two independent chains!) 51 | 52 | Chains are rebased by the number of changesets in them. This ensures longer 53 | chains are merged faster, and less rebases are triggered. In the future, this 54 | might be extended to other strategies. 55 | 56 | ### Submitting changesets 57 | The submitqueue has a Trigger() function, which gets periodically executed. 58 | 59 | It can keep a reference to one single chain across multiple runs. This is 60 | necessary if it previously rebased one chain to current HEAD and needs to wait 61 | some time until CI feedback is there. If it wouldn't keep that state, it would 62 | pick another chain (with +1 from CI) and trigger a rebase on that one, so 63 | depending on CI run times and trigger intervals, if not keepig this information 64 | it'd end up rebasing all unrebased changesets on the same HEAD, and then just 65 | pick one, instead of waiting for the one to finish. 66 | 67 | The Trigger() function first instructs the gerrit client to fetch changesets 68 | and assemble chains. 69 | If there is a `wipChain` from a previous run, we check if it can still be found 70 | in the newly assembled list of chains (it still needs to contain the same 71 | number of changesets. Commit IDs may differ, because the code doesn't reassemble 72 | a `wipChain` after scheduling a rebase. 73 | If the `wipChain` could be refreshed, we update the pointer with the newly 74 | assembled chain. If we couldn't find it, we drop it. 75 | 76 | Now, we enter the main for loop. The first half of the loop checks various 77 | conditions of the current `wipChain`, and if successful, does the submit 78 | ("Submit phase"), the second half will pick a suitable new `wipChain`, and 79 | potentially do a rebase ("Pick phase"). 80 | 81 | #### Submit phase 82 | We check if there is an existing `wipChain`. If there isn't, we immediately go to 83 | the "pick" phase. 84 | 85 | The `wipChain` still needs to be rebased on `HEAD` (otherwise, the submit queue 86 | advanced outside of gerrit), and should not fail CI (logical merge conflict) - 87 | otherwise we discard it, and continue with the picking phase. 88 | 89 | If the `wipChain` still contains a changeset awaiting CI feedback, we `return` 90 | from the `Trigger()` function (and go back to sleep). 91 | 92 | If the changeset is "submittable" in gerrit speech, and has the necessary 93 | submit queue tag set, we submit it. 94 | 95 | #### Pick phase 96 | The pick phase finds a new `wipChain`. It'll first try to find one that already 97 | is rebased on the current `HEAD` (so the loop can just continue, and the next 98 | submit phase simply submit), and otherwise fall back to a not-yet-rebased 99 | chain. Because the rebase mandates waiting for CI, the code `return`s the 100 | `Trigger()` function, so it'll be called again after waiting some time. 101 | 102 | ## Compile and Run 103 | ```sh 104 | go generate 105 | GERRIT_PASSWORD=mypassword go run main.go --url https://gerrit.mydomain.com --username myuser --project myproject 106 | ``` 107 | -------------------------------------------------------------------------------- /frontend/frontend.go: -------------------------------------------------------------------------------- 1 | package frontend 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "html/template" 11 | 12 | "github.com/apex/log" 13 | 14 | "github.com/flokli/gerrit-queue/gerrit" 15 | "github.com/flokli/gerrit-queue/misc" 16 | "github.com/flokli/gerrit-queue/submitqueue" 17 | ) 18 | 19 | //go:embed templates 20 | var templates embed.FS 21 | 22 | // loadTemplate loads a list of templates, relative to the templates root, and a 23 | // FuncMap, and returns a template object 24 | func loadTemplate(templateNames []string, funcMap template.FuncMap) (*template.Template, error) { 25 | if len(templateNames) == 0 { 26 | return nil, fmt.Errorf("templateNames can't be empty") 27 | } 28 | tmpl := template.New(templateNames[0]).Funcs(funcMap) 29 | 30 | for _, templateName := range templateNames { 31 | r, err := templates.Open("/" + templateName) 32 | if err != nil { 33 | return nil, err 34 | } 35 | defer r.Close() 36 | contents, err := io.ReadAll(r) 37 | if err != nil { 38 | return nil, err 39 | } 40 | tmpl, err = tmpl.Parse(string(contents)) 41 | if err != nil { 42 | return nil, err 43 | } 44 | } 45 | 46 | return tmpl, nil 47 | } 48 | 49 | // MakeFrontend returns a http.Handler 50 | func MakeFrontend(rotatingLogHandler *misc.RotatingLogHandler, gerritClient *gerrit.Client, runner *submitqueue.Runner) http.Handler { 51 | projectName := gerritClient.GetProjectName() 52 | branchName := gerritClient.GetBranchName() 53 | 54 | mux := http.NewServeMux() 55 | mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { 56 | var wipChain *gerrit.Chain = nil 57 | HEAD := "" 58 | currentlyRunning := runner.IsCurrentlyRunning() 59 | 60 | // don't trigger operations requiring a lock 61 | if !currentlyRunning { 62 | wipChain = runner.GetWIPChain() 63 | HEAD = gerritClient.GetHEAD() 64 | } 65 | 66 | funcMap := template.FuncMap{ 67 | "changesetURL": func(changeset *gerrit.Changeset) string { 68 | return gerritClient.GetChangesetURL(changeset) 69 | }, 70 | "levelToClasses": func(level log.Level) string { 71 | switch level { 72 | case log.DebugLevel: 73 | return "text-muted" 74 | case log.InfoLevel: 75 | return "text-info" 76 | case log.WarnLevel: 77 | return "text-warning" 78 | case log.ErrorLevel: 79 | return "text-danger" 80 | case log.FatalLevel: 81 | return "text-danger" 82 | default: 83 | return "text-white" 84 | } 85 | }, 86 | "fieldsToJSON": func(fields log.Fields) string { 87 | jsonData, _ := json.Marshal(fields) 88 | return string(jsonData) 89 | }, 90 | } 91 | 92 | tmpl := template.Must(loadTemplate([]string{ 93 | "index.tmpl.html", 94 | "chain.tmpl.html", 95 | "changeset.tmpl.html", 96 | }, funcMap)) 97 | 98 | err := tmpl.ExecuteTemplate(w, "index.tmpl.html", map[string]interface{}{ 99 | // Config 100 | "projectName": projectName, 101 | "branchName": branchName, 102 | 103 | // State 104 | "currentlyRunning": currentlyRunning, 105 | "wipChain": wipChain, 106 | "HEAD": HEAD, 107 | 108 | // History 109 | "memory": rotatingLogHandler, 110 | }) 111 | 112 | if err != nil { 113 | log.Warnf("failed to execute template: %s", err) 114 | } 115 | }) 116 | return mux 117 | } 118 | -------------------------------------------------------------------------------- /frontend/templates/chain.tmpl.html: -------------------------------------------------------------------------------- 1 | {{ define "chain" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{ range $changeset := .ChangeSets }} 15 | {{ block "changeset" $changeset }}{{ end }} 16 | {{ end }} 17 | 18 |
OwnerChangesetFlags
Chain with {{ len .ChangeSets }} changes
19 | {{ end }} -------------------------------------------------------------------------------- /frontend/templates/changeset.tmpl.html: -------------------------------------------------------------------------------- 1 | {{ define "changeset" }} 2 | 3 | {{ .OwnerName }} 4 | 5 | {{ .Subject }} (#{{ .Number }})
6 | {{ .CommitID }} 7 | 8 | 9 | 10 | {{ if .IsVerified }}+1 (CI){{ end }} 11 | {{ if .IsCodeReviewed }}+2 (CR){{ end }} 12 | 13 | 14 | 15 | {{ end }} -------------------------------------------------------------------------------- /frontend/templates/index.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gerrit Submit Queue 5 | 6 | 7 | 8 | 9 | 10 | 31 |
32 |

Info

33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 | 51 | 54 | 55 | 56 |
Project Name:{{ .projectName }}
Branch Name:{{ .branchName }}
Currently running: 46 | {{ if .currentlyRunning }}yes{{ else }}no{{ end }} 47 |
HEAD: 52 | {{ if .HEAD }}{{ .HEAD }}{{ else }}-{{ end }} 53 |
57 | 58 |

WIP Chain

59 | {{ if .wipChain }} 60 | {{ block "chain" .wipChain }}{{ end }} 61 | {{ else }} 62 | - 63 | {{ end }} 64 | 65 |

Log

66 | {{ range $entry := .memory.Entries }} 67 |
68 |
{{ $entry.Timestamp.Format "2006-01-02 15:04:05 UTC"}}
69 |
{{ $entry.Message }}
70 |
71 |
72 | {{ fieldsToJSON $entry.Fields }} 73 |
74 | {{ end }} 75 | 76 | 77 | -------------------------------------------------------------------------------- /gerrit/chain.go: -------------------------------------------------------------------------------- 1 | package gerrit 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/apex/log" 8 | ) 9 | 10 | // Chain represents a list of successive changesets with an unbroken parent -> child relation, 11 | // starting from the parent. 12 | type Chain struct { 13 | ChangeSets []*Changeset 14 | } 15 | 16 | // GetParentCommitIDs returns the parent commit IDs 17 | func (s *Chain) GetParentCommitIDs() ([]string, error) { 18 | if len(s.ChangeSets) == 0 { 19 | return nil, fmt.Errorf("can't return parent on a chain with zero ChangeSets") 20 | } 21 | return s.ChangeSets[0].ParentCommitIDs, nil 22 | } 23 | 24 | // GetLeafCommitID returns the commit id of the last commit in ChangeSets 25 | func (s *Chain) GetLeafCommitID() (string, error) { 26 | if len(s.ChangeSets) == 0 { 27 | return "", fmt.Errorf("can't return leaf on a chain with zero ChangeSets") 28 | } 29 | return s.ChangeSets[len(s.ChangeSets)-1].CommitID, nil 30 | } 31 | 32 | // Validate checks that the chain contains a properly ordered and connected chain of commits 33 | func (s *Chain) Validate() error { 34 | logger := log.WithField("chain", s) 35 | // an empty chain is invalid 36 | if len(s.ChangeSets) == 0 { 37 | return fmt.Errorf("an empty chain is invalid") 38 | } 39 | 40 | previousCommitID := "" 41 | for i, changeset := range s.ChangeSets { 42 | // we can't really check the parent of the first commit 43 | // so skip verifying that one 44 | logger.WithFields(log.Fields{ 45 | "changeset": changeset.String(), 46 | "previousCommitID": fmt.Sprintf("%.7s", previousCommitID), 47 | }).Debug(" - verifying changeset") 48 | 49 | parentCommitIDs := changeset.ParentCommitIDs 50 | if len(parentCommitIDs) == 0 { 51 | return fmt.Errorf("changesets without any parent are not supported") 52 | } 53 | // we don't check parents of the first changeset in a chain 54 | if i != 0 { 55 | if len(parentCommitIDs) != 1 { 56 | return fmt.Errorf("merge commits in the middle of a chain are not supported (only at the beginning)") 57 | } 58 | if parentCommitIDs[0] != previousCommitID { 59 | return fmt.Errorf("changesets parent commit id doesn't match previous commit id") 60 | } 61 | } 62 | // update previous commit id for the next loop iteration 63 | previousCommitID = changeset.CommitID 64 | } 65 | return nil 66 | } 67 | 68 | // AllChangesets applies a filter function on all of the changesets in the chain. 69 | // returns true if it returns true for all changesets, false otherwise 70 | func (s *Chain) AllChangesets(f func(c *Changeset) bool) bool { 71 | for _, changeset := range s.ChangeSets { 72 | if !f(changeset) { 73 | return false 74 | } 75 | } 76 | return true 77 | } 78 | 79 | func (s *Chain) String() string { 80 | var sb strings.Builder 81 | sb.WriteString(fmt.Sprintf("Chain[%d]", len(s.ChangeSets))) 82 | if len(s.ChangeSets) == 0 { 83 | sb.WriteString("()\n") 84 | return sb.String() 85 | } 86 | parentCommitIDs, err := s.GetParentCommitIDs() 87 | if err == nil { 88 | if len(parentCommitIDs) == 1 { 89 | sb.WriteString(fmt.Sprintf("(parent: %.7s)", parentCommitIDs[0])) 90 | } else { 91 | sb.WriteString("(merge: ") 92 | 93 | for i, parentCommitID := range parentCommitIDs { 94 | sb.WriteString(fmt.Sprintf("%.7s", parentCommitID)) 95 | if i < len(parentCommitIDs) { 96 | sb.WriteString(", ") 97 | } 98 | } 99 | 100 | sb.WriteString(")") 101 | 102 | } 103 | } 104 | sb.WriteString(fmt.Sprintf("(%.7s..%.7s)", 105 | s.ChangeSets[0].CommitID, 106 | s.ChangeSets[len(s.ChangeSets)-1].CommitID)) 107 | return sb.String() 108 | } 109 | -------------------------------------------------------------------------------- /gerrit/chains.go: -------------------------------------------------------------------------------- 1 | package gerrit 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/apex/log" 7 | ) 8 | 9 | // AssembleChain consumes a list of changesets, and groups them together to chains. 10 | // 11 | // Initially, every changeset is put in its own individual chain. 12 | // 13 | // we maintain a lookup table, mapLeafToChain, 14 | // which allows to lookup a chain by its leaf commit id 15 | // We concat chains in a fixpoint approach 16 | // because both appending and prepending is much more complex. 17 | // Concatenation moves changesets of the later changeset in the previous one 18 | // in a cleanup phase, we remove orphaned chains (those without any changesets inside) 19 | // afterwards, we do an integrity check, just to be on the safe side. 20 | func AssembleChain(changesets []*Changeset, logger *log.Logger) ([]*Chain, error) { 21 | chains := make([]*Chain, 0) 22 | mapLeafToChain := make(map[string]*Chain, 0) 23 | 24 | for _, changeset := range changesets { 25 | l := logger.WithField("changeset", changeset.String()) 26 | 27 | l.Debug("creating initial chain") 28 | chain := &Chain{ 29 | ChangeSets: []*Changeset{changeset}, 30 | } 31 | chains = append(chains, chain) 32 | mapLeafToChain[changeset.CommitID] = chain 33 | } 34 | 35 | // Combine chain using a fixpoint approach, with a max iteration count. 36 | logger.Debug("glueing together phase") 37 | for i := 1; i < 100; i++ { 38 | didUpdate := false 39 | logger.Debugf("at iteration %d", i) 40 | for j, chain := range chains { 41 | l := logger.WithFields(log.Fields{ 42 | "i": i, 43 | "j": j, 44 | "chain": chain.String(), 45 | }) 46 | parentCommitIDs, err := chain.GetParentCommitIDs() 47 | if err != nil { 48 | return chains, err 49 | } 50 | if len(parentCommitIDs) != 1 { 51 | // We can't append merge commits to other chains 52 | l.Infof("No single parent, skipping.") 53 | continue 54 | } 55 | parentCommitID := parentCommitIDs[0] 56 | l.Debug("Looking for a predecessor.") 57 | // if there's another chain that has this parent as a leaf, glue together 58 | if otherChain, ok := mapLeafToChain[parentCommitID]; ok { 59 | if otherChain == chain { 60 | continue 61 | } 62 | l = l.WithField("otherChain", otherChain) 63 | 64 | myLeafCommitID, err := chain.GetLeafCommitID() 65 | if err != nil { 66 | return chains, err 67 | } 68 | 69 | // append our changesets to the other chain 70 | l.Debug("Splicing together.") 71 | otherChain.ChangeSets = append(otherChain.ChangeSets, chain.ChangeSets...) 72 | 73 | delete(mapLeafToChain, parentCommitID) 74 | mapLeafToChain[myLeafCommitID] = otherChain 75 | 76 | // orphan our chain 77 | chain.ChangeSets = []*Changeset{} 78 | // remove the orphaned chain from the lookup table 79 | delete(mapLeafToChain, myLeafCommitID) 80 | 81 | didUpdate = true 82 | } else { 83 | l.Debug("Not found.") 84 | } 85 | } 86 | chains = removeOrphanedChains(chains) 87 | if !didUpdate { 88 | logger.Infof("converged after %d iterations", i) 89 | break 90 | } 91 | } 92 | 93 | // Check integrity, just to be on the safe side. 94 | for _, chain := range chains { 95 | l := logger.WithField("chain", chain.String()) 96 | l.Debugf("checking integrity") 97 | err := chain.Validate() 98 | if err != nil { 99 | l.Errorf("checking integrity failed: %s", err) 100 | } 101 | } 102 | return chains, nil 103 | } 104 | 105 | // removeOrphanedChains removes all empty chains (that contain zero changesets) 106 | func removeOrphanedChains(chains []*Chain) []*Chain { 107 | newChains := []*Chain{} 108 | for _, chain := range chains { 109 | if len(chain.ChangeSets) != 0 { 110 | newChains = append(newChains, chain) 111 | } 112 | } 113 | return newChains 114 | } 115 | 116 | // SortChains sorts a list of chains by the number of changesets in each chain, descending 117 | func SortChains(chains []*Chain) []*Chain { 118 | newChains := make([]*Chain, len(chains)) 119 | copy(newChains, chains) 120 | sort.Slice(newChains, func(i, j int) bool { 121 | // the weight depends on the amount of changesets in the chain 122 | return len(chains[i].ChangeSets) > len(chains[j].ChangeSets) 123 | }) 124 | return newChains 125 | } 126 | -------------------------------------------------------------------------------- /gerrit/changeset.go: -------------------------------------------------------------------------------- 1 | package gerrit 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | goGerrit "github.com/andygrunwald/go-gerrit" 8 | "github.com/apex/log" 9 | ) 10 | 11 | // Changeset represents a single changeset 12 | type Changeset struct { 13 | changeInfo *goGerrit.ChangeInfo 14 | ChangeID string 15 | Number int 16 | Verified int 17 | CodeReviewed int 18 | Autosubmit int 19 | Submittable bool 20 | CommitID string 21 | ParentCommitIDs []string 22 | OwnerName string 23 | Subject string 24 | } 25 | 26 | // MakeChangeset creates a new Changeset object out of a goGerrit.ChangeInfo object 27 | func MakeChangeset(changeInfo *goGerrit.ChangeInfo) *Changeset { 28 | return &Changeset{ 29 | changeInfo: changeInfo, 30 | ChangeID: changeInfo.ChangeID, 31 | Number: changeInfo.Number, 32 | Verified: labelInfoToInt(changeInfo.Labels["Verified"]), 33 | CodeReviewed: labelInfoToInt(changeInfo.Labels["Code-Review"]), 34 | Autosubmit: labelInfoToInt(changeInfo.Labels["Autosubmit"]), 35 | Submittable: changeInfo.Submittable, 36 | CommitID: changeInfo.CurrentRevision, // yes, this IS the commit ID. 37 | ParentCommitIDs: getParentCommitIDs(changeInfo), 38 | OwnerName: changeInfo.Owner.Name, 39 | Subject: changeInfo.Subject, 40 | } 41 | } 42 | 43 | // IsAutosubmit returns true if the changeset is intended to be 44 | // automatically submitted by gerrit-queue. 45 | // 46 | // This is determined by the Change Owner setting +1 on the 47 | // "Autosubmit" label. 48 | func (c *Changeset) IsAutosubmit() bool { 49 | return c.Autosubmit == 1 50 | } 51 | 52 | // IsVerified returns true if the changeset passed CI, 53 | // that's when somebody left the Approved (+1) on the "Verified" label 54 | func (c *Changeset) IsVerified() bool { 55 | return c.Verified == 1 56 | } 57 | 58 | // IsCodeReviewed returns true if the changeset passed code review, 59 | // that's when somebody left the Recommended (+2) on the "Code-Review" label 60 | func (c *Changeset) IsCodeReviewed() bool { 61 | return c.CodeReviewed == 2 62 | } 63 | 64 | func (c *Changeset) String() string { 65 | var b bytes.Buffer 66 | b.WriteString("Changeset") 67 | b.WriteString(fmt.Sprintf("(commitID: %.7s, author: %s, subject: %s, submittable: %v)", 68 | c.CommitID, c.OwnerName, c.Subject, c.Submittable)) 69 | return b.String() 70 | } 71 | 72 | // FilterChangesets filters a list of Changeset by a given filter function 73 | func FilterChangesets(changesets []*Changeset, f func(*Changeset) bool) []*Changeset { 74 | newChangesets := make([]*Changeset, 0) 75 | for _, changeset := range changesets { 76 | if f(changeset) { 77 | newChangesets = append(newChangesets, changeset) 78 | } else { 79 | log.WithField("changeset", changeset.String()).Debug("dropped by filter") 80 | } 81 | } 82 | return newChangesets 83 | } 84 | 85 | // labelInfoToInt converts a goGerrit.LabelInfo to -2…+2 int 86 | // its behaviour for other labels is undefined. 87 | func labelInfoToInt(labelInfo goGerrit.LabelInfo) int { 88 | if labelInfo.Recommended.AccountID != 0 { 89 | return 2 90 | } 91 | if labelInfo.Approved.AccountID != 0 { 92 | return 1 93 | } 94 | if labelInfo.Disliked.AccountID != 0 { 95 | return -1 96 | } 97 | if labelInfo.Rejected.AccountID != 0 { 98 | return -2 99 | } 100 | return 0 101 | } 102 | 103 | // getParentCommitIDs returns the parent commit IDs of the goGerrit.ChangeInfo 104 | // There is usually only one parent commit ID, except for merge commits. 105 | func getParentCommitIDs(changeInfo *goGerrit.ChangeInfo) []string { 106 | // obtain the RevisionInfo object 107 | revisionInfo := changeInfo.Revisions[changeInfo.CurrentRevision] 108 | 109 | // obtain the Commit object 110 | commit := revisionInfo.Commit 111 | 112 | commitIDs := make([]string, len(commit.Parents)) 113 | for i, commit := range commit.Parents { 114 | commitIDs[i] = commit.Commit 115 | } 116 | return commitIDs 117 | } 118 | -------------------------------------------------------------------------------- /gerrit/changeset_test.go: -------------------------------------------------------------------------------- 1 | package gerrit 2 | 3 | import ( 4 | "testing" 5 | 6 | goGerrit "github.com/andygrunwald/go-gerrit" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsAutosubmit(t *testing.T) { 11 | emptyChangeInfo := &goGerrit.ChangeInfo{} 12 | assert.Equal(t, false, MakeChangeset(emptyChangeInfo).IsAutosubmit(), "A changeset without the Autosubmit label present shouldn't be autosubmittable") 13 | 14 | // +1 15 | changeInfoWithAutosubmitLabelSet := &goGerrit.ChangeInfo{ 16 | Labels: map[string]goGerrit.LabelInfo{ 17 | "Autosubmit": { 18 | Approved: goGerrit.AccountInfo{AccountID: 1}, 19 | }, 20 | }, 21 | } 22 | assert.Equal(t, true, MakeChangeset(changeInfoWithAutosubmitLabelSet).IsAutosubmit(), "Autosubmit label set to +1 should be autosubmittable") 23 | 24 | // ensure some common label values don't trigger autosubmit. We only trigger on a "+1" 25 | 26 | // +2 27 | changeInfoWithAutosubmitLabelSetToPlusTwo := &goGerrit.ChangeInfo{ 28 | Labels: map[string]goGerrit.LabelInfo{ 29 | "Autosubmit": { 30 | Recommended: goGerrit.AccountInfo{AccountID: 1}, 31 | }, 32 | }, 33 | } 34 | assert.Equal(t, false, MakeChangeset(changeInfoWithAutosubmitLabelSetToPlusTwo).IsAutosubmit(), "Autosubmit label set to +2 should not be autosubmittable") 35 | 36 | // -1 37 | changeInfoWithAutosubmitLabelSetToMinusOne := &goGerrit.ChangeInfo{ 38 | Labels: map[string]goGerrit.LabelInfo{ 39 | "Autosubmit": { 40 | Disliked: goGerrit.AccountInfo{AccountID: 1}, 41 | }, 42 | }, 43 | } 44 | assert.Equal(t, false, MakeChangeset(changeInfoWithAutosubmitLabelSetToMinusOne).IsAutosubmit(), "Autosubmit label set to -1 should not be autosubmittable") 45 | 46 | // -2 47 | changeInfoWithAutosubmitLabelSetToMinusTwo := &goGerrit.ChangeInfo{ 48 | Labels: map[string]goGerrit.LabelInfo{ 49 | "Autosubmit": { 50 | Rejected: goGerrit.AccountInfo{AccountID: 1}, 51 | }, 52 | }, 53 | } 54 | assert.Equal(t, false, MakeChangeset(changeInfoWithAutosubmitLabelSetToMinusTwo).IsAutosubmit(), "Autosubmit label set to -2 should not be autosubmittable") 55 | } 56 | -------------------------------------------------------------------------------- /gerrit/client.go: -------------------------------------------------------------------------------- 1 | package gerrit 2 | 3 | import ( 4 | "fmt" 5 | 6 | goGerrit "github.com/andygrunwald/go-gerrit" 7 | "github.com/apex/log" 8 | 9 | "net/url" 10 | ) 11 | 12 | // passed to gerrit when retrieving changesets 13 | var additionalFields = []string{ 14 | "LABELS", 15 | "CURRENT_REVISION", 16 | "CURRENT_COMMIT", 17 | "DETAILED_ACCOUNTS", 18 | "SUBMITTABLE", 19 | } 20 | 21 | // IClient defines the gerrit.Client interface 22 | type IClient interface { 23 | Refresh() error 24 | GetHEAD() string 25 | GetBaseURL() string 26 | GetChangesetURL(changeset *Changeset) string 27 | SubmitChangeset(changeset *Changeset) (*Changeset, error) 28 | RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) 29 | ChangesetIsRebasedOnHEAD(changeset *Changeset) bool 30 | ChainIsRebasedOnHEAD(chain *Chain) bool 31 | FilterChains(filter func(s *Chain) bool) []*Chain 32 | FindFirstChain(filter func(s *Chain) bool) *Chain 33 | } 34 | 35 | var _ IClient = &Client{} 36 | 37 | // Client provides some ways to interact with a gerrit instance 38 | type Client struct { 39 | client *goGerrit.Client 40 | logger *log.Logger 41 | baseURL string 42 | projectName string 43 | branchName string 44 | chains []*Chain 45 | head string 46 | } 47 | 48 | // NewClient initializes a new gerrit client 49 | func NewClient(logger *log.Logger, URL, username, password, projectName, branchName string) (*Client, error) { 50 | urlParsed, err := url.Parse(URL) 51 | if err != nil { 52 | return nil, err 53 | } 54 | urlParsed.User = url.UserPassword(username, password) 55 | 56 | goGerritClient, err := goGerrit.NewClient(urlParsed.String(), nil) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return &Client{ 61 | client: goGerritClient, 62 | baseURL: URL, 63 | logger: logger, 64 | projectName: projectName, 65 | branchName: branchName, 66 | }, nil 67 | } 68 | 69 | // refreshHEAD queries the commit ID of the selected project and branch 70 | func (c *Client) refreshHEAD() (string, error) { 71 | branchInfo, _, err := c.client.Projects.GetBranch(c.projectName, c.branchName) 72 | if err != nil { 73 | return "", err 74 | } 75 | return branchInfo.Revision, nil 76 | } 77 | 78 | // GetHEAD returns the internally stored HEAD 79 | func (c *Client) GetHEAD() string { 80 | return c.head 81 | } 82 | 83 | // Refresh causes the client to refresh internal view of gerrit 84 | func (c *Client) Refresh() error { 85 | c.logger.Debug("refreshing from gerrit") 86 | HEAD, err := c.refreshHEAD() 87 | if err != nil { 88 | return err 89 | } 90 | c.head = HEAD 91 | 92 | var queryString = fmt.Sprintf("status:open project:%s branch:%s", c.projectName, c.branchName) 93 | c.logger.Debugf("fetching changesets: %s", queryString) 94 | changesets, err := c.fetchChangesets(queryString) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | c.logger.Infof("assembling chains") 100 | chains, err := AssembleChain(changesets, c.logger) 101 | if err != nil { 102 | return err 103 | } 104 | chains = SortChains(chains) 105 | c.chains = chains 106 | return nil 107 | } 108 | 109 | // fetchChangesets fetches a list of changesets matching a passed query string 110 | func (c *Client) fetchChangesets(queryString string) (changesets []*Changeset, Error error) { 111 | opt := &goGerrit.QueryChangeOptions{} 112 | opt.Query = []string{ 113 | queryString, 114 | } 115 | opt.AdditionalFields = additionalFields 116 | changes, _, err := c.client.Changes.QueryChanges(opt) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | changesets = make([]*Changeset, 0) 122 | for _, change := range *changes { 123 | changesets = append(changesets, MakeChangeset(&change)) 124 | } 125 | 126 | return changesets, nil 127 | } 128 | 129 | // fetchChangeset downloads an existing Changeset from gerrit, by its ID 130 | // Gerrit's API is a bit sparse, and only returns what you explicitly ask it 131 | // This is used to refresh an existing changeset with more data. 132 | func (c *Client) fetchChangeset(changeID string) (*Changeset, error) { 133 | opt := goGerrit.ChangeOptions{} 134 | opt.AdditionalFields = []string{"LABELS", "DETAILED_ACCOUNTS"} 135 | changeInfo, _, err := c.client.Changes.GetChange(changeID, &opt) 136 | if err != nil { 137 | return nil, err 138 | } 139 | return MakeChangeset(changeInfo), nil 140 | } 141 | 142 | // SubmitChangeset submits a given changeset, and returns a changeset afterwards. 143 | func (c *Client) SubmitChangeset(changeset *Changeset) (*Changeset, error) { 144 | changeInfo, _, err := c.client.Changes.SubmitChange(changeset.ChangeID, &goGerrit.SubmitInput{}) 145 | if err != nil { 146 | return nil, err 147 | } 148 | c.head = changeInfo.CurrentRevision 149 | return c.fetchChangeset(changeInfo.ChangeID) 150 | } 151 | 152 | // RebaseChangeset rebases a given changeset on top of a given ref 153 | func (c *Client) RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) { 154 | changeInfo, _, err := c.client.Changes.RebaseChange(changeset.ChangeID, &goGerrit.RebaseInput{ 155 | Base: ref, 156 | OnBehalfOfUploader: true, 157 | }) 158 | if err != nil { 159 | return changeset, err 160 | } 161 | return c.fetchChangeset(changeInfo.ChangeID) 162 | } 163 | 164 | // GetBaseURL returns the gerrit base URL 165 | func (c *Client) GetBaseURL() string { 166 | return c.baseURL 167 | } 168 | 169 | // GetProjectName returns the configured gerrit project name 170 | func (c *Client) GetProjectName() string { 171 | return c.projectName 172 | } 173 | 174 | // GetBranchName returns the configured gerrit branch name 175 | func (c *Client) GetBranchName() string { 176 | return c.branchName 177 | } 178 | 179 | // GetChangesetURL returns the URL to view a given changeset 180 | func (c *Client) GetChangesetURL(changeset *Changeset) string { 181 | return fmt.Sprintf("%s/c/%s/+/%d", c.GetBaseURL(), c.projectName, changeset.Number) 182 | } 183 | 184 | // ChangesetIsRebasedOnHEAD returns true if the changeset is rebased on the current HEAD 185 | func (c *Client) ChangesetIsRebasedOnHEAD(changeset *Changeset) bool { 186 | if len(changeset.ParentCommitIDs) != 1 { 187 | return false 188 | } 189 | return changeset.ParentCommitIDs[0] == c.head 190 | } 191 | 192 | // ChainIsRebasedOnHEAD returns true if the whole chain is rebased on the current HEAD 193 | // this is already the case if the first changeset in the chain is rebased on the current HEAD 194 | func (c *Client) ChainIsRebasedOnHEAD(chain *Chain) bool { 195 | // an empty chain should not exist 196 | if len(chain.ChangeSets) == 0 { 197 | return false 198 | } 199 | return c.ChangesetIsRebasedOnHEAD(chain.ChangeSets[0]) 200 | } 201 | 202 | // FilterChains returns a subset of all chains, passing the given filter function 203 | func (c *Client) FilterChains(filter func(s *Chain) bool) []*Chain { 204 | matchedChains := []*Chain{} 205 | for _, chain := range c.chains { 206 | if filter(chain) { 207 | matchedChains = append(matchedChains, chain) 208 | } 209 | } 210 | return matchedChains 211 | } 212 | 213 | // FindFirstChain returns the first chain that matches the filter, or nil if none was found 214 | func (c *Client) FindFirstChain(filter func(s *Chain) bool) *Chain { 215 | for _, chain := range c.chains { 216 | if filter(chain) { 217 | return chain 218 | } 219 | } 220 | return nil 221 | } 222 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flokli/gerrit-queue 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 7 | github.com/apex/log v1.1.1 8 | github.com/stretchr/testify v1.7.0 9 | github.com/urfave/cli v1.22.1 10 | ) 11 | 12 | replace github.com/andygrunwald/go-gerrit => github.com/lukegb/go-gerrit v0.0.0-20231016235128-b5317f06cc92 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 h1:9PvNa6zH6gOW4VVfbAx5rjDLpxunG+RSaXQB+8TEv4w= 3 | github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8/go.mod h1:0iuRQp6WJ44ts+iihy5E/WlPqfg5RNeQxOmzRkxCdtk= 4 | github.com/apex/log v1.1.1 h1:BwhRZ0qbjYtTob0I+2M+smavV0kOC8XgcnGZcyL9liA= 5 | github.com/apex/log v1.1.1/go.mod h1:Ls949n1HFtXfbDcjiTTFQqkVUrte0puoIBfO3SVgwOA= 6 | github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= 7 | github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= 8 | github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 9 | github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 15 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 16 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 17 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 18 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 20 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 21 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 22 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 23 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 24 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 25 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 26 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= 27 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 28 | github.com/lukegb/go-gerrit v0.0.0-20231016235128-b5317f06cc92 h1:pJh+AdGbrPeTk6VRqzyc02kZ0ttLi8sPaS+sltPu2fQ= 29 | github.com/lukegb/go-gerrit v0.0.0-20231016235128-b5317f06cc92/go.mod h1:SeP12EkHZxEVjuJ2HZET304NBtHGG2X6w2Gzd0QXAZw= 30 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 31 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 32 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 33 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 34 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 35 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 36 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 37 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 38 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= 42 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 43 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 44 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 45 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 46 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 47 | github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= 48 | github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= 49 | github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 52 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 53 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 54 | github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= 55 | github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= 56 | github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= 57 | github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= 58 | github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= 59 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 60 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 61 | golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 62 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 63 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 64 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 65 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 67 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 68 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 69 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 71 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 72 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 75 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 76 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 77 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 78 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 79 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "net/http" 8 | 9 | "github.com/flokli/gerrit-queue/frontend" 10 | "github.com/flokli/gerrit-queue/gerrit" 11 | "github.com/flokli/gerrit-queue/misc" 12 | "github.com/flokli/gerrit-queue/submitqueue" 13 | 14 | "github.com/urfave/cli" 15 | 16 | "github.com/apex/log" 17 | "github.com/apex/log/handlers/multi" 18 | "github.com/apex/log/handlers/text" 19 | ) 20 | 21 | func main() { 22 | var URL, username, password, projectName, branchName string 23 | var fetchOnly bool 24 | var triggerInterval int 25 | 26 | app := cli.NewApp() 27 | app.Name = "gerrit-queue" 28 | app.Description = "A merge bot for gerrit" 29 | 30 | app.Flags = []cli.Flag{ 31 | cli.StringFlag{ 32 | Name: "url", 33 | Usage: "URL to the gerrit instance", 34 | EnvVar: "GERRIT_URL", 35 | Destination: &URL, 36 | Required: true, 37 | }, 38 | cli.StringFlag{ 39 | Name: "username", 40 | Usage: "Username to use to login to gerrit", 41 | EnvVar: "GERRIT_USERNAME", 42 | Destination: &username, 43 | Required: true, 44 | }, 45 | cli.StringFlag{ 46 | Name: "password", 47 | Usage: "Password to use to login to gerrit", 48 | EnvVar: "GERRIT_PASSWORD", 49 | Destination: &password, 50 | Required: true, 51 | }, 52 | cli.StringFlag{ 53 | Name: "project", 54 | Usage: "Gerrit project name to run the submit queue for", 55 | EnvVar: "GERRIT_PROJECT", 56 | Destination: &projectName, 57 | Required: true, 58 | }, 59 | cli.StringFlag{ 60 | Name: "branch", 61 | Usage: "Destination branch", 62 | EnvVar: "GERRIT_BRANCH", 63 | Destination: &branchName, 64 | Value: "master", 65 | }, 66 | cli.IntFlag{ 67 | Name: "trigger-interval", 68 | Usage: "How often we should trigger ourselves (interval in seconds)", 69 | EnvVar: "SUBMIT_QUEUE_TRIGGER_INTERVAL", 70 | Destination: &triggerInterval, 71 | Value: 600, 72 | }, 73 | cli.BoolFlag{ 74 | Name: "fetch-only", 75 | Usage: "Only fetch changes and assemble queue, but don't actually write", 76 | EnvVar: "SUBMIT_QUEUE_FETCH_ONLY", 77 | Destination: &fetchOnly, 78 | }, 79 | } 80 | 81 | rotatingLogHandler := misc.NewRotatingLogHandler(10000) 82 | l := &log.Logger{ 83 | Handler: multi.New( 84 | text.New(os.Stderr), 85 | rotatingLogHandler, 86 | ), 87 | Level: log.DebugLevel, 88 | } 89 | 90 | app.Action = func(c *cli.Context) error { 91 | gerrit, err := gerrit.NewClient(l, URL, username, password, projectName, branchName) 92 | if err != nil { 93 | return err 94 | } 95 | log.Infof("Successfully connected to gerrit at %s", URL) 96 | 97 | runner := submitqueue.NewRunner(l, gerrit) 98 | 99 | handler := frontend.MakeFrontend(rotatingLogHandler, gerrit, runner) 100 | 101 | // fetch only on first run 102 | err = runner.Trigger(fetchOnly) 103 | if err != nil { 104 | log.Error(err.Error()) 105 | } 106 | 107 | // ticker 108 | go func() { 109 | for { 110 | time.Sleep(time.Duration(triggerInterval) * time.Second) 111 | err = runner.Trigger(fetchOnly) 112 | if err != nil { 113 | log.Error(err.Error()) 114 | } 115 | } 116 | }() 117 | 118 | server := http.Server{ 119 | Addr: ":8080", 120 | Handler: handler, 121 | } 122 | 123 | err = server.ListenAndServe() 124 | if err != nil { 125 | log.Fatalf(err.Error()) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | err := app.Run(os.Args) 132 | if err != nil { 133 | log.Fatal(err.Error()) 134 | } 135 | 136 | // TODOS: 137 | // - handle event log, either by accepting webhooks, or by streaming events? 138 | } 139 | -------------------------------------------------------------------------------- /misc/rotatingloghandler.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/apex/log" 7 | ) 8 | 9 | // RotatingLogHandler implementation. 10 | type RotatingLogHandler struct { 11 | mu sync.Mutex 12 | Entries []*log.Entry 13 | maxEntries int 14 | } 15 | 16 | // NewRotatingLogHandler creates a new rotating log handler 17 | func NewRotatingLogHandler(maxEntries int) *RotatingLogHandler { 18 | return &RotatingLogHandler{ 19 | maxEntries: maxEntries, 20 | } 21 | } 22 | 23 | // HandleLog implements log.Handler. 24 | func (h *RotatingLogHandler) HandleLog(e *log.Entry) error { 25 | h.mu.Lock() 26 | defer h.mu.Unlock() 27 | // drop tail if we have more entries than maxEntries 28 | if len(h.Entries) > h.maxEntries { 29 | h.Entries = append([]*log.Entry{e}, h.Entries[:(h.maxEntries-2)]...) 30 | } else { 31 | h.Entries = append([]*log.Entry{e}, h.Entries...) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /submitqueue/runner.go: -------------------------------------------------------------------------------- 1 | package submitqueue 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/apex/log" 8 | 9 | "github.com/flokli/gerrit-queue/gerrit" 10 | ) 11 | 12 | // Runner is a struct existing across the lifetime of a single run of the submit queue 13 | // it contains a mutex to avoid being run multiple times. 14 | // In fact, it even cancels runs while another one is still in progress. 15 | // It contains a Gerrit object facilitating access, a log object, the configured submit queue tag 16 | // and a `wipChain` (only populated if waiting for a rebase) 17 | type Runner struct { 18 | mut sync.Mutex 19 | currentlyRunning bool 20 | wipChain *gerrit.Chain 21 | logger *log.Logger 22 | gerrit *gerrit.Client 23 | } 24 | 25 | // NewRunner creates a new Runner struct 26 | func NewRunner(logger *log.Logger, gerrit *gerrit.Client) *Runner { 27 | return &Runner{ 28 | logger: logger, 29 | gerrit: gerrit, 30 | } 31 | } 32 | 33 | // isAutoSubmittable determines if something could be autosubmitted, potentially requiring a rebase 34 | // for this, it needs to: 35 | // - have the "Autosubmit" label set to +1 36 | // - have gerrit's 'submittable' field set to true 37 | // 38 | // it doesn't check if the chain is rebased on HEAD 39 | func (r *Runner) isAutoSubmittable(s *gerrit.Chain) bool { 40 | for _, c := range s.ChangeSets { 41 | if !c.Submittable || !c.IsAutosubmit() { 42 | return false 43 | } 44 | } 45 | return true 46 | } 47 | 48 | // IsCurrentlyRunning returns true if the runner is currently running 49 | func (r *Runner) IsCurrentlyRunning() bool { 50 | return r.currentlyRunning 51 | } 52 | 53 | // GetWIPChain returns the current wipChain, if any, nil otherwiese 54 | // Acquires a lock, so check with IsCurrentlyRunning first 55 | func (r *Runner) GetWIPChain() *gerrit.Chain { 56 | r.mut.Lock() 57 | defer func() { 58 | r.mut.Unlock() 59 | }() 60 | return r.wipChain 61 | } 62 | 63 | // Trigger gets triggered periodically 64 | func (r *Runner) Trigger(fetchOnly bool) error { 65 | // TODO: If CI fails, remove the auto-submit labels => rules.pl 66 | // Only one trigger can run at the same time 67 | r.mut.Lock() 68 | if r.currentlyRunning { 69 | return fmt.Errorf("already running, skipping") 70 | } 71 | r.currentlyRunning = true 72 | r.mut.Unlock() 73 | defer func() { 74 | r.mut.Lock() 75 | r.currentlyRunning = false 76 | r.mut.Unlock() 77 | }() 78 | 79 | // Prepare the work by creating a local cache of gerrit state 80 | err := r.gerrit.Refresh() 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // early return if we only want to fetch 86 | if fetchOnly { 87 | return nil 88 | } 89 | 90 | if r.wipChain != nil { 91 | // refresh wipChain with how it looks like in gerrit now 92 | wipChain := r.gerrit.FindFirstChain(func(s *gerrit.Chain) bool { 93 | // the new wipChain needs to have the same number of changesets 94 | if len(r.wipChain.ChangeSets) != len(s.ChangeSets) { 95 | return false 96 | } 97 | // … and the same ChangeIDs. 98 | for idx, c := range s.ChangeSets { 99 | if r.wipChain.ChangeSets[idx].ChangeID != c.ChangeID { 100 | return false 101 | } 102 | } 103 | return true 104 | }) 105 | if wipChain == nil { 106 | r.logger.WithField("wipChain", r.wipChain).Warn("wipChain has disappeared") 107 | r.wipChain = nil 108 | } else { 109 | r.wipChain = wipChain 110 | } 111 | } 112 | 113 | for { 114 | // initialize logger 115 | r.logger.Info("Running") 116 | if r.wipChain != nil { 117 | // if we have a wipChain 118 | l := r.logger.WithField("wipChain", r.wipChain) 119 | l.Info("Checking wipChain") 120 | 121 | // discard wipChain not rebased on HEAD 122 | // we rebase them at the end of the loop, so this means master advanced without going through the submit queue 123 | if !r.gerrit.ChainIsRebasedOnHEAD(r.wipChain) { 124 | l.Warnf("HEAD has moved to %v while still waiting for wipChain, discarding it", r.gerrit.GetHEAD()) 125 | r.wipChain = nil 126 | continue 127 | } 128 | 129 | // we now need to check CI feedback: 130 | // wipChain might have failed CI in the meantime 131 | for _, c := range r.wipChain.ChangeSets { 132 | if c == nil { 133 | l.Error("BUG: changeset is nil") 134 | continue 135 | } 136 | if c.Verified < 0 { 137 | l.WithField("failingChangeset", c).Warnf("wipChain failed CI in the meantime, discarding.") 138 | r.wipChain = nil 139 | continue 140 | } 141 | } 142 | 143 | // it might still be waiting for CI 144 | for _, c := range r.wipChain.ChangeSets { 145 | if c == nil { 146 | l.Error("BUG: changeset is nil") 147 | continue 148 | } 149 | if c.Verified == 0 { 150 | l.WithField("pendingChangeset", c).Warnf("still waiting for CI feedback in wipChain, going back to sleep.") 151 | // break the loop, take a look at it at the next trigger. 152 | return nil 153 | } 154 | } 155 | 156 | // it might be autosubmittable 157 | if r.isAutoSubmittable(r.wipChain) { 158 | l.Infof("submitting wipChain") 159 | // if the WIP changeset is ready (auto submittable and rebased on HEAD), submit 160 | for _, changeset := range r.wipChain.ChangeSets { 161 | _, err := r.gerrit.SubmitChangeset(changeset) 162 | if err != nil { 163 | l.WithField("changeset", changeset).Error("error submitting changeset") 164 | r.wipChain = nil 165 | return err 166 | } 167 | } 168 | r.wipChain = nil 169 | } else { 170 | l.Error("BUG: wipChain is not autosubmittable") 171 | r.wipChain = nil 172 | } 173 | } 174 | 175 | r.logger.Info("Looking for chains ready to submit") 176 | // Find chain, that: 177 | // * has the auto-submit label 178 | // * has +2 review 179 | // * has +1 CI 180 | // * is rebased on master 181 | chain := r.gerrit.FindFirstChain(func(s *gerrit.Chain) bool { 182 | return r.isAutoSubmittable(s) && s.ChangeSets[0].ParentCommitIDs[0] == r.gerrit.GetHEAD() 183 | }) 184 | if chain != nil { 185 | r.logger.WithField("chain", chain).Info("Found chain to submit without necessary rebase") 186 | r.wipChain = chain 187 | continue 188 | } 189 | 190 | // Find chain, that: 191 | // * has the auto-submit label 192 | // * has +2 review 193 | // * has +1 CI 194 | // * is NOT rebased on master 195 | chain = r.gerrit.FindFirstChain(r.isAutoSubmittable) 196 | if chain == nil { 197 | r.logger.Info("no more submittable chain found, going back to sleep.") 198 | break 199 | } 200 | 201 | l := r.logger.WithField("chain", chain) 202 | l.Info("found chain, which needs a rebase") 203 | // TODO: move into Client.RebaseChangeset function 204 | head := r.gerrit.GetHEAD() 205 | for _, changeset := range chain.ChangeSets { 206 | changeset, err := r.gerrit.RebaseChangeset(changeset, head) 207 | if err != nil { 208 | l.Error(err.Error()) 209 | return err 210 | } 211 | head = changeset.CommitID 212 | } 213 | // we don't need to care about updating the rebased changesets or getting the updated HEAD, 214 | // as we'll refetch it on the beginning of the next trigger anyways 215 | r.wipChain = chain 216 | break 217 | } 218 | 219 | r.logger.Info("Run complete") 220 | return nil 221 | } 222 | --------------------------------------------------------------------------------