├── .gitignore ├── CONTRIBUTING ├── Dockerfile ├── LICENSE ├── README.md ├── api.go ├── build-deploy.sh ├── cmd └── checker │ ├── checker.go │ ├── gcp.go │ └── main.go ├── deploy.sh ├── gerrit ├── server.go └── types.go ├── go.mod ├── go.sum ├── server.go └── vm-deploy.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | cmd/checker/checker 3 | cmd/fmtserver/fmtserver 4 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | 2 | Before sending a patch, please fill out a Google CLA, 3 | using the following form: 4 | 5 | https://cla.developers.google.com/clas 6 | 7 | For submitting patches, please use gerrit: 8 | 9 | * Make sure all tests pass: 10 | 11 | go test github.com/google/fmtserver/... 12 | 13 | * Add the following to your .git/config: 14 | 15 | [remote "gerrit"] 16 | url = https://gerrit.googlesource.com/fmtserver 17 | fetch = +refs/heads/*:refs/remotes/origin/* 18 | 19 | * Create an account at https://gerrit-review.googlesource.com/ 20 | and follow `Settings -> HTTP Credentials -> Obtain password` 21 | 22 | * Add a Change ID to the bottom of your commit message: run the following, 23 | and append its output to your commmit message 24 | 25 | echo "Change-Id: I"$(head -c 20 /dev/urandom | sha1sum | awk '{print $1}') 26 | 27 | Or install the hook: 28 | 29 | curl -Lo .git/hooks/commit-msg https://gerrit-review.googlesource.com/tools/hooks/commit-msg 30 | chmod +x .git/hooks/commit-msg 31 | 32 | * Upload to gerrit: 33 | 34 | git push gerrit HEAD:refs/for/master 35 | 36 | * Add hanwen@google.com as a reviewer in the Web UI 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 as builder 2 | 3 | LABEL maintainer="Han-Wen Nienhuys " 4 | 5 | # Set the Current Working Directory inside the container 6 | WORKDIR /app 7 | 8 | # Copy go mod and sum files 9 | COPY go.mod go.sum ./ 10 | 11 | RUN go version 12 | 13 | # Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed 14 | RUN go mod download 15 | 16 | # Copy the source from the current directory to the Working Directory inside the container 17 | COPY . . 18 | 19 | # Build the Go app. The netgo tag ensures we build a static binary. 20 | RUN go build -tags netgo -o gerrit-linter ./cmd/checker 21 | RUN go build -tags netgo -o buildifier github.com/bazelbuild/buildtools/buildifier 22 | RUN curl -L -o google-java-format.jar https://github.com/google/google-java-format/releases/download/google-java-format-1.7/google-java-format-1.7-all-deps.jar 23 | RUN chmod +x google-java-format.jar 24 | RUN cp $(which gofmt) . 25 | 26 | FROM alpine:latest 27 | 28 | RUN apk --no-cache add ca-certificates 29 | 30 | WORKDIR /app/ 31 | 32 | # Copy the Pre-built binary file from the previous stage 33 | COPY --from=builder /app/gerrit-linter . 34 | COPY --from=builder /app/gofmt . 35 | COPY --from=builder /app/buildifier . 36 | COPY --from=builder /app/google-java-format.jar . 37 | ENTRYPOINT [ "/app/gerrit-linter" ] 38 | CMD [] 39 | -------------------------------------------------------------------------------- /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 | # GERRITFMT 2 | 3 | This is a style verifier intended to be used with the Gerrit checks 4 | plugin. 5 | 6 | ## HOW TO USE 7 | 8 | 1. Install formatters: 9 | 10 | ```sh 11 | go install github.com/bazelbuild/buildtools/buildifier 12 | curl -o google-java-format.jar https://github.com/google/google-java-format/releases/download/google-java-format-1.7/google-java-format-1.7-all-deps.jar 13 | ``` 14 | 15 | 1. Obtain an HTTP password, and put it in `testsite-auth`. The format is 16 | `username:secret`. 17 | 18 | 19 | 2. Register a checker 20 | 21 | ```sh 22 | go run ./cmd/checker -auth_file=testsite-auth --gerrit http://localhost:8080 \ 23 | --language go --repo gerrit --register 24 | ``` 25 | 26 | 3. Make sure the checker is there 27 | 28 | ```sh 29 | go run ./cmd/checker -auth_file=testsite-auth --gerrit http://localhost:8080 \ 30 | --list 31 | ``` 32 | 33 | 4. Start the server 34 | 35 | ```sh 36 | go run ./cmd/checker -auth_file=testsite-auth --gerrit http://localhost:8080 37 | ``` 38 | 39 | 40 | 41 | ## DESIGN 42 | 43 | For simplicity of deployment, the gerrit-linter checker is stateless. All the 44 | necessary data is encoded in the checker UUID. 45 | 46 | 47 | ## TODO 48 | 49 | * handle file types (symlink) and deletions 50 | 51 | * more formatters: clang-format, typescript, jsformat, ... ? 52 | 53 | * isolate each formatter to run with a separate gvisor/docker 54 | container. 55 | 56 | * tests: the only way to test this reliably is to spin up a gerrit server, 57 | and create changes against the server. 58 | 59 | * Update the list of checkers periodically. 60 | 61 | ## SECURITY 62 | 63 | This currently runs the formatters without sandboxing. Critical bugs (heap 64 | overflow, buffer overflow) in formatters can be escalated to obtain the OAuth2 65 | token used for authentication. 66 | 67 | The currently supported formatters are written in Java and Go, so this should 68 | not be an issue. 69 | 70 | 71 | ## DOCKER ON GCP 72 | 73 | The following example shows how to build a Docker image hosted on GCP, in the 74 | project `api-project-164060093628`. 75 | 76 | ``` 77 | VERSION=$(date --iso-8601=minutes | tr -d ':' | tr '[A-Z]' '[a-z]'| sed \ 78 | 's|\+.*$||')-$(git rev-parse --short HEAD) 79 | NAME=gcr.io/api-project-164060093628/gerrit-linter:${VERSION} 80 | docker build -t ${NAME} -f Dockerfile . 81 | docker push ${NAME} 82 | ``` 83 | 84 | To deploy onto a GCP VM, configure the VM to have scope 85 | `https://www.googleapis.com/auth/gerritcodereview`: 86 | 87 | ```sh 88 | cloud beta compute instances set-scopes VM-NAME --scopes=https://www.googleapis.com/auth/gerritcodereview 89 | ``` 90 | 91 | 92 | ## DISCLAIMER 93 | 94 | This is not an official Google product 95 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gerritlinter 16 | 17 | type File struct { 18 | Language string 19 | Name string 20 | Content []byte 21 | } 22 | 23 | type FormatRequest struct { 24 | Files []File 25 | } 26 | 27 | type FormattedFile struct { 28 | File 29 | Message string 30 | } 31 | 32 | type FormatReply struct { 33 | Files []FormattedFile 34 | } 35 | -------------------------------------------------------------------------------- /build-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eux 3 | 4 | if [[ -d .git ]]; then 5 | VERSION=-$(git show --pretty=format:%h -q) 6 | fi 7 | VERSION=$(date --iso-8601=minutes | tr -d ':' | sed 's|\+.*$||')${VERSION} 8 | 9 | dest=gerrit-linter-${VERSION} 10 | mkdir -p ${dest} 11 | 12 | trap "rm -rf ${dest}" 'EXIT' 13 | 14 | go build -o ${dest}/gerrit-linter ./cmd/checker 15 | 16 | if [[ ! -f google-java-format.jar ]] ; then 17 | curl -Lo google-java-format.jar https://github.com/google/google-java-format/releases/download/google-java-format-1.7/google-java-format-1.7-all-deps.jar 18 | fi 19 | 20 | cp google-java-format.jar ${dest}/ 21 | chmod +x ${dest}/*.jar 22 | 23 | go build -o ${dest}/buildifier github.com/bazelbuild/buildtools/buildifier 24 | 25 | cp $(which gofmt) ${dest}/ 26 | 27 | chmod 755 ${dest}/* 28 | tar cfz ${dest}.tar.gz ${dest}/ 29 | -------------------------------------------------------------------------------- /cmd/checker/checker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bytes" 19 | "crypto/sha1" 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "log" 24 | "net/rpc" 25 | "strconv" 26 | "strings" 27 | "time" 28 | 29 | linter "github.com/google/gerrit-linter" 30 | "github.com/google/gerrit-linter/gerrit" 31 | ) 32 | 33 | // gerritChecker run formatting checks against a gerrit server. 34 | type gerritChecker struct { 35 | server *gerrit.Server 36 | 37 | todo chan *gerrit.PendingChecksInfo 38 | } 39 | 40 | // checkerScheme is the scheme by which we are registered in the Gerrit server. 41 | const checkerScheme = "fmt" 42 | 43 | // ListCheckers returns all the checkers for our scheme. 44 | func (gc *gerritChecker) ListCheckers() ([]*gerrit.CheckerInfo, error) { 45 | c, err := gc.server.GetPath("a/plugins/checks/checkers/") 46 | if err != nil { 47 | log.Fatalf("ListCheckers: %v", err) 48 | } 49 | 50 | var out []*gerrit.CheckerInfo 51 | if err := gerrit.Unmarshal(c, &out); err != nil { 52 | return nil, err 53 | } 54 | 55 | filtered := out[:0] 56 | for _, o := range out { 57 | if !strings.HasPrefix(o.UUID, checkerScheme+":") { 58 | continue 59 | } 60 | if _, ok := checkerLanguage(o.UUID); !ok { 61 | continue 62 | } 63 | 64 | filtered = append(filtered, o) 65 | } 66 | return filtered, nil 67 | } 68 | 69 | // PostChecker creates or changes a checker. It sets up a checker on 70 | // the given repo, for the given language. 71 | func (gc *gerritChecker) PostChecker(repo, language string, update bool) (*gerrit.CheckerInfo, error) { 72 | hash := sha1.New() 73 | hash.Write([]byte(repo)) 74 | 75 | uuid := fmt.Sprintf("%s:%s-%x", checkerScheme, language, hash.Sum(nil)) 76 | in := gerrit.CheckerInput{ 77 | UUID: uuid, 78 | Name: language + " formatting", 79 | Repository: repo, 80 | Description: "check source code formatting.", 81 | Status: "ENABLED", 82 | Query: linter.Formatters[language].Query, 83 | } 84 | 85 | body, err := json.Marshal(&in) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | path := "a/plugins/checks/checkers/" 91 | if update { 92 | path += uuid 93 | } 94 | content, err := gc.server.PostPath(path, "application/json", body) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | out := gerrit.CheckerInfo{} 100 | if err := gerrit.Unmarshal(content, &out); err != nil { 101 | return nil, err 102 | } 103 | 104 | return &out, nil 105 | } 106 | 107 | // checkerLanguage extracts the language to check for from a checker UUID. 108 | func checkerLanguage(uuid string) (string, bool) { 109 | uuid = strings.TrimPrefix(uuid, checkerScheme+":") 110 | fields := strings.Split(uuid, "-") 111 | if len(fields) != 2 { 112 | return "", false 113 | } 114 | return fields[0], true 115 | } 116 | 117 | // NewGerritChecker creates a server that periodically checks a gerrit 118 | // server for pending checks. 119 | func NewGerritChecker(server *gerrit.Server) (*gerritChecker, error) { 120 | gc := &gerritChecker{ 121 | server: server, 122 | todo: make(chan *gerrit.PendingChecksInfo, 5), 123 | } 124 | 125 | go gc.pendingLoop() 126 | return gc, nil 127 | } 128 | 129 | // errIrrelevant is a marker error value used for checks that don't apply for a change. 130 | var errIrrelevant = errors.New("irrelevant") 131 | 132 | // checkChange checks a (change, patchset) for correct formatting in the given language. It returns 133 | // a list of complaints, or the errIrrelevant error if there is nothing to do. 134 | func (c *gerritChecker) checkChange(changeID string, psID int, language string) ([]string, error) { 135 | ch, err := c.server.GetChange(changeID, strconv.Itoa(psID)) 136 | if err != nil { 137 | return nil, err 138 | } 139 | req := linter.FormatRequest{} 140 | for n, f := range ch.Files { 141 | cfg := linter.Formatters[language] 142 | if cfg == nil { 143 | return nil, fmt.Errorf("language %q not configured", language) 144 | } 145 | if !cfg.Regex.MatchString(n) { 146 | continue 147 | } 148 | 149 | req.Files = append(req.Files, 150 | linter.File{ 151 | Language: language, 152 | Name: n, 153 | Content: f.Content, 154 | }) 155 | } 156 | if len(req.Files) == 0 { 157 | return nil, errIrrelevant 158 | } 159 | 160 | rep := linter.FormatReply{} 161 | if err := linter.Format(&req, &rep); err != nil { 162 | _, ok := err.(rpc.ServerError) 163 | if ok { 164 | return nil, fmt.Errorf("server returned: %s", err) 165 | } 166 | return nil, err 167 | } 168 | 169 | var msgs []string 170 | for _, f := range rep.Files { 171 | orig := ch.Files[f.Name] 172 | if orig == nil { 173 | return nil, fmt.Errorf("result had unknown file %q", f.Name) 174 | } 175 | if !bytes.Equal(f.Content, orig.Content) { 176 | msg := f.Message 177 | if msg == "" { 178 | msg = "found a difference" 179 | } 180 | msgs = append(msgs, fmt.Sprintf("%s: %s", f.Name, msg)) 181 | log.Printf("file %s: %s", f.Name, f.Message) 182 | } else { 183 | log.Printf("file %s: OK", f.Name) 184 | } 185 | } 186 | 187 | return msgs, nil 188 | } 189 | 190 | // pendingLoop periodically contacts gerrit to find new checks to 191 | // execute. It should be executed in a goroutine. 192 | func (c *gerritChecker) pendingLoop() { 193 | for { 194 | // TODO: real rate limiting. 195 | time.Sleep(10 * time.Second) 196 | 197 | pending, err := c.server.PendingChecksByScheme(checkerScheme) 198 | if err != nil { 199 | log.Printf("PendingChecksByScheme: %v", err) 200 | continue 201 | } 202 | 203 | if len(pending) == 0 { 204 | log.Printf("no pending checks") 205 | } 206 | 207 | for _, pc := range pending { 208 | select { 209 | case c.todo <- pc: 210 | default: 211 | log.Println("too busy; dropping pending check.") 212 | } 213 | } 214 | } 215 | } 216 | 217 | // Serve runs the serve loop, executing formatters for checks that 218 | // need it. 219 | func (gc *gerritChecker) Serve() { 220 | for p := range gc.todo { 221 | // TODO: parallelism?. 222 | if err := gc.executeCheck(p); err != nil { 223 | log.Printf("executeCheck(%v): %v", p, err) 224 | } 225 | } 226 | } 227 | 228 | // status encodes the checker states. 229 | type status int 230 | 231 | var ( 232 | statusUnset status = 0 233 | statusIrrelevant status = 4 234 | statusRunning status = 1 235 | statusFail status = 2 236 | statusSuccessful status = 3 237 | ) 238 | 239 | func (s status) String() string { 240 | return map[status]string{ 241 | statusUnset: "UNSET", 242 | statusIrrelevant: "IRRELEVANT", 243 | statusRunning: "RUNNING", 244 | statusFail: "FAILED", 245 | statusSuccessful: "SUCCESSFUL", 246 | }[s] 247 | } 248 | 249 | // executeCheck executes the pending checks specified in the argument. 250 | func (gc *gerritChecker) executeCheck(pc *gerrit.PendingChecksInfo) error { 251 | log.Println("checking", pc) 252 | 253 | changeID := strconv.Itoa(pc.PatchSet.ChangeNumber) 254 | psID := pc.PatchSet.PatchSetID 255 | for uuid := range pc.PendingChecks { 256 | now := gerrit.Timestamp(time.Now()) 257 | checkInput := gerrit.CheckInput{ 258 | CheckerUUID: uuid, 259 | State: statusRunning.String(), 260 | Started: &now, 261 | } 262 | log.Printf("posted %s", &checkInput) 263 | _, err := gc.server.PostCheck( 264 | changeID, psID, &checkInput) 265 | if err != nil { 266 | return err 267 | } 268 | 269 | var status status 270 | msg := "" 271 | lang, ok := checkerLanguage(uuid) 272 | if !ok { 273 | return fmt.Errorf("uuid %q had unknown language", uuid) 274 | } else { 275 | msgs, err := gc.checkChange(changeID, psID, lang) 276 | if err == errIrrelevant { 277 | status = statusIrrelevant 278 | } else if err != nil { 279 | status = statusFail 280 | log.Printf("checkChange(%s, %d, %q): %v", changeID, psID, lang, err) 281 | msgs = []string{fmt.Sprintf("tool failure: %v", err)} 282 | } else if len(msgs) == 0 { 283 | status = statusSuccessful 284 | } else { 285 | status = statusFail 286 | } 287 | msg = strings.Join(msgs, ", ") 288 | if len(msg) > 1000 { 289 | msg = msg[:995] + "..." 290 | } 291 | } 292 | 293 | log.Printf("status %s for lang %s on %v", status, lang, pc.PatchSet) 294 | checkInput = gerrit.CheckInput{ 295 | CheckerUUID: uuid, 296 | State: status.String(), 297 | Message: msg, 298 | } 299 | log.Printf("posted %s", &checkInput) 300 | 301 | if _, err := gc.server.PostCheck(changeID, psID, &checkInput); err != nil { 302 | return err 303 | } 304 | } 305 | return nil 306 | } 307 | -------------------------------------------------------------------------------- /cmd/checker/gcp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google Ltd. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "fmt" 21 | "io/ioutil" 22 | "log" 23 | "net/http" 24 | "strings" 25 | "sync" 26 | "time" 27 | 28 | "github.com/google/gerrit-linter/gerrit" 29 | ) 30 | 31 | // The token as it comes from metadata service. 32 | type gcpToken struct { 33 | AccessToken string `json:"access_token"` 34 | ExpiresIn int `json:"expires_in"` 35 | TokenType string `json:"token_type"` 36 | } 37 | 38 | // tokenCache fetches a bearer token from the GCP metadata service, 39 | // and refreshes it before it expires. 40 | type tokenCache struct { 41 | account string 42 | 43 | mu sync.Mutex 44 | current *gcpToken 45 | } 46 | 47 | // Implement the Authenticator interface. 48 | func (tc *tokenCache) Authenticate(req *http.Request) error { 49 | tc.mu.Lock() 50 | defer tc.mu.Unlock() 51 | 52 | if tc.current == nil { 53 | return fmt.Errorf("no token") 54 | } 55 | 56 | req.Header.Set("Authorization", "Bearer "+tc.current.AccessToken) 57 | return nil 58 | } 59 | 60 | // The name of the scope that is necessary to access googlesource.com 61 | // gerrit instances. 62 | const gerritScope = "https://www.googleapis.com/auth/gerritcodereview" 63 | 64 | // scopeURL returns the URL where GCP serves scopes for an account. 65 | func scopeURL(account string) string { 66 | return fmt.Sprintf("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/%s/scopes", 67 | account) 68 | } 69 | 70 | // tokenURL returns the URL where GCP serves tokens for an account. 71 | func tokenURL(account string) string { 72 | return fmt.Sprintf("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/%s/token", 73 | account) 74 | } 75 | 76 | // fetchScopes returns the scopes for the configured service account. 77 | func (tc *tokenCache) fetchScopes() ([]string, error) { 78 | req, err := http.NewRequest("GET", scopeURL(tc.account), nil) 79 | if err != nil { 80 | return nil, err 81 | } 82 | req.Header.Set("Metadata-Flavor", "Google") 83 | 84 | resp, err := http.DefaultClient.Do(req.WithContext(context.Background())) 85 | if err != nil { 86 | return nil, err 87 | } 88 | defer resp.Body.Close() 89 | all, _ := ioutil.ReadAll(resp.Body) 90 | 91 | return strings.Split(strings.TrimSpace(string(all)), "\n"), nil 92 | } 93 | 94 | // fetch gets the token from the metadata server. 95 | func (tc *tokenCache) fetchToken() (*gcpToken, error) { 96 | req, err := http.NewRequest("GET", tokenURL(tc.account), nil) 97 | if err != nil { 98 | return nil, err 99 | } 100 | req.Header.Set("Metadata-Flavor", "Google") 101 | 102 | resp, err := http.DefaultClient.Do(req.WithContext(context.Background())) 103 | if err != nil { 104 | return nil, err 105 | } 106 | defer resp.Body.Close() 107 | all, _ := ioutil.ReadAll(resp.Body) 108 | 109 | if resp.StatusCode != http.StatusOK { 110 | return nil, fmt.Errorf("%v failed (%d): %s", req, resp.StatusCode, string(all)) 111 | } 112 | 113 | tok := &gcpToken{} 114 | if err := json.Unmarshal(all, tok); err != nil { 115 | return nil, fmt.Errorf("can't unmarshal %s: %v", string(all), err) 116 | } 117 | 118 | return tok, nil 119 | } 120 | 121 | // NewGCPServiceAccount returns a Authenticator that will use GCP 122 | // bearer-tokens to authenticate against a googlesource.com Gerrit 123 | // instance. The tokens are refreshed automatically. 124 | func NewGCPServiceAccount(account string) (gerrit.Authenticator, error) { 125 | tc := tokenCache{ 126 | account: account, 127 | } 128 | 129 | scopes, err := tc.fetchScopes() 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | found := false 135 | for _, s := range scopes { 136 | if s == gerritScope { 137 | found = true 138 | break 139 | } 140 | } 141 | if !found { 142 | return nil, fmt.Errorf("missing scope %q, got %q", gerritScope, scopes) 143 | } 144 | 145 | tc.current, err = tc.fetchToken() 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | go tc.loop() 151 | 152 | return &tc, nil 153 | } 154 | 155 | // loop refreshes the token periodically. 156 | func (tc *tokenCache) loop() { 157 | delaySecs := tc.current.ExpiresIn - 1 158 | for { 159 | time.Sleep(time.Duration(delaySecs) * time.Second) 160 | tok, err := tc.fetchToken() 161 | if err != nil { 162 | log.Printf("fetching token failed: %s", err) 163 | delaySecs = 2 164 | } else { 165 | delaySecs = tok.ExpiresIn - 1 166 | } 167 | 168 | tc.mu.Lock() 169 | tc.current = tok 170 | tc.mu.Unlock() 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /cmd/checker/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "flag" 20 | "io/ioutil" 21 | "log" 22 | "net/url" 23 | "os" 24 | 25 | linter "github.com/google/gerrit-linter" 26 | "github.com/google/gerrit-linter/gerrit" 27 | ) 28 | 29 | func main() { 30 | gerritURL := flag.String("gerrit", "", "URL to gerrit host") 31 | register := flag.Bool("register", false, "Register with the host") 32 | update := flag.Bool("update", false, "Update an existing checker on the host") 33 | list := flag.Bool("list", false, "List pending checks") 34 | agent := flag.String("agent", "fmtserver", "user-agent for the fmtserver.") 35 | gcpServiceAccount := flag.String("gcp_service_account", "", "A GCP service account ID to run this as") 36 | authFile := flag.String("auth_file", "", "file containing user:password") 37 | repo := flag.String("repo", "", "the repository (project) name to apply the checker to.") 38 | language := flag.String("language", "", "the language that the checker should apply to.") 39 | flag.Parse() 40 | if *gerritURL == "" { 41 | log.Fatal("must set --gerrit") 42 | } 43 | 44 | u, err := url.Parse(*gerritURL) 45 | if err != nil { 46 | log.Fatalf("url.Parse: %v", err) 47 | } 48 | 49 | if (*authFile == "" && *gcpServiceAccount == "") || (*authFile != "" && *gcpServiceAccount != "") { 50 | log.Fatal("must set one of --auth_file or --gcp_service_account") 51 | } 52 | 53 | g := gerrit.New(*u) 54 | 55 | g.UserAgent = *agent 56 | 57 | if *authFile != "" { 58 | content, err := ioutil.ReadFile(*authFile) 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | g.Authenticator = gerrit.NewBasicAuth(string(content)) 63 | } 64 | if *gcpServiceAccount != "" { 65 | g.Authenticator, err = NewGCPServiceAccount(*gcpServiceAccount) 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | } 70 | 71 | // Do a GET first to complete any cookie dance, because POST 72 | // aren't redirected properly. Also, this avoids spamming logs with 73 | // failure messages. 74 | if _, err := g.GetPath("a/accounts/self"); err != nil { 75 | log.Fatalf("accounts/self: %v", err) 76 | } 77 | 78 | gc, err := NewGerritChecker(g) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | if *list { 84 | if out, err := gc.ListCheckers(); err != nil { 85 | log.Fatalf("List: %v", err) 86 | } else { 87 | for _, ch := range out { 88 | json, _ := json.Marshal(ch) 89 | os.Stdout.Write(json) 90 | os.Stdout.Write([]byte{'\n'}) 91 | } 92 | } 93 | 94 | os.Exit(0) 95 | } 96 | 97 | if *register || *update { 98 | if *repo == "" { 99 | log.Fatalf("need to set --repo") 100 | } 101 | 102 | if *language == "" { 103 | log.Fatalf("must set --language.") 104 | } 105 | 106 | if !linter.IsSupported(*language) { 107 | log.Fatalf("language is not supported. Choices are %s", linter.SupportedLanguages()) 108 | } 109 | 110 | ch, err := gc.PostChecker(*repo, *language, *update) 111 | if err != nil { 112 | log.Fatalf("CreateChecker: %v", err) 113 | } 114 | log.Printf("CreateChecker result: %v", ch) 115 | os.Exit(0) 116 | } 117 | 118 | gc.Serve() 119 | } 120 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | latest=$(ls -1tr | grep tar.gz | tail -1) 4 | 5 | scp vm-deploy.sh ${latest} ${IP}: 6 | ssh ${IP} "sh vm-deploy.sh ${latest}" 7 | -------------------------------------------------------------------------------- /gerrit/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gerrit 16 | 17 | import ( 18 | "bytes" 19 | "encoding/base64" 20 | "encoding/json" 21 | "fmt" 22 | "io/ioutil" 23 | "net/http" 24 | "net/url" 25 | "path" 26 | "strings" 27 | ) 28 | 29 | // Server represents a single Gerrit host. 30 | type Server struct { 31 | UserAgent string 32 | URL url.URL 33 | Client http.Client 34 | 35 | // Issue trace requests. 36 | Debug bool 37 | 38 | Authenticator Authenticator 39 | } 40 | 41 | type Authenticator interface { 42 | // Authenticate adds an authentication header to an outgoing request. 43 | Authenticate(req *http.Request) error 44 | } 45 | 46 | // BasicAuth adds the "Basic Authorization" header to an outgoing request. 47 | type BasicAuth struct { 48 | // Base64 encoded user:secret string. 49 | EncodedBasicAuth string 50 | } 51 | 52 | // NewBasicAuth creates a BasicAuth authenticator. |who| should be a 53 | // "user:secret" string. 54 | func NewBasicAuth(who string) *BasicAuth { 55 | auth := strings.TrimSpace(who) 56 | encoded := make([]byte, base64.StdEncoding.EncodedLen(len(auth))) 57 | base64.StdEncoding.Encode(encoded, []byte(auth)) 58 | return &BasicAuth{ 59 | EncodedBasicAuth: string(encoded), 60 | } 61 | } 62 | 63 | func (b *BasicAuth) Authenticate(req *http.Request) error { 64 | req.Header.Set("Authorization", "Basic "+string(b.EncodedBasicAuth)) 65 | return nil 66 | } 67 | 68 | // New creates a Gerrit Server for the given URL. 69 | func New(u url.URL) *Server { 70 | g := &Server{ 71 | URL: u, 72 | } 73 | 74 | g.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { 75 | return nil 76 | } 77 | 78 | return g 79 | } 80 | 81 | // GetPath runs a Get on the given path. 82 | func (g *Server) GetPath(p string) ([]byte, error) { 83 | u := g.URL 84 | u.Path = path.Join(u.Path, p) 85 | if strings.HasSuffix(p, "/") && !strings.HasSuffix(u.Path, "/") { 86 | // Ugh. 87 | u.Path += "/" 88 | } 89 | return g.Get(&u) 90 | } 91 | 92 | // Do runs a HTTP request against the remote server. 93 | func (g *Server) Do(req *http.Request) (*http.Response, error) { 94 | req.Header.Set("User-Agent", g.UserAgent) 95 | if g.Authenticator != nil { 96 | if err := g.Authenticator.Authenticate(req); err != nil { 97 | return nil, err 98 | } 99 | } 100 | 101 | if g.Debug { 102 | if req.URL.RawQuery != "" { 103 | req.URL.RawQuery += "&trace=0x1" 104 | } else { 105 | req.URL.RawQuery += "trace=0x1" 106 | } 107 | } 108 | return g.Client.Do(req) 109 | } 110 | 111 | // Get runs a HTTP GET request on the given URL. 112 | func (g *Server) Get(u *url.URL) ([]byte, error) { 113 | req, err := http.NewRequest("GET", u.String(), nil) 114 | if err != nil { 115 | return nil, err 116 | } 117 | rep, err := g.Do(req) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if rep.StatusCode/100 != 2 { 122 | return nil, fmt.Errorf("Get %s: status %d", u.String(), rep.StatusCode) 123 | } 124 | 125 | defer rep.Body.Close() 126 | return ioutil.ReadAll(rep.Body) 127 | } 128 | 129 | // PostPath posts the given data onto a path. 130 | func (g *Server) PostPath(p string, contentType string, content []byte) ([]byte, error) { 131 | u := g.URL 132 | u.Path = path.Join(u.Path, p) 133 | if strings.HasSuffix(p, "/") && !strings.HasSuffix(u.Path, "/") { 134 | // Ugh. 135 | u.Path += "/" 136 | } 137 | req, err := http.NewRequest("POST", u.String(), bytes.NewBuffer(content)) 138 | if err != nil { 139 | return nil, err 140 | } 141 | req.Header.Set("Content-Type", contentType) 142 | rep, err := g.Do(req) 143 | if err != nil { 144 | return nil, err 145 | } 146 | if rep.StatusCode/100 != 2 { 147 | return nil, fmt.Errorf("Post %s: status %d", u.String(), rep.StatusCode) 148 | } 149 | 150 | defer rep.Body.Close() 151 | return ioutil.ReadAll(rep.Body) 152 | } 153 | 154 | // GetContent returns the file content from a file in a change. 155 | func (g *Server) GetContent(changeID string, revID string, fileID string) ([]byte, error) { 156 | u := g.URL 157 | path := path.Join(u.Path, fmt.Sprintf("changes/%s/revisions/%s/files/", 158 | url.PathEscape(changeID), revID)) 159 | u.Path = path + "/" + fileID + "/content" 160 | u.RawPath = path + "/" + url.PathEscape(fileID) + "/content" 161 | c, err := g.Get(&u) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | dest := make([]byte, base64.StdEncoding.DecodedLen(len(c))) 167 | n, err := base64.StdEncoding.Decode(dest, c) 168 | if err != nil { 169 | return nil, err 170 | } 171 | return dest[:n], nil 172 | } 173 | 174 | // GetChange returns the Change (including file contents) for a given change. 175 | func (g *Server) GetChange(changeID string, revID string) (*Change, error) { 176 | content, err := g.GetPath(fmt.Sprintf("changes/%s/revisions/%s/files/", 177 | url.PathEscape(changeID), revID)) 178 | if err != nil { 179 | return nil, err 180 | } 181 | content = bytes.TrimPrefix(content, jsonPrefix) 182 | 183 | files := map[string]*File{} 184 | if err := json.Unmarshal(content, &files); err != nil { 185 | return nil, err 186 | } 187 | 188 | for name, file := range files { 189 | if file.Status == "D" { 190 | continue 191 | } 192 | c, err := g.GetContent(changeID, revID, name) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | files[name].Content = c 198 | } 199 | return &Change{files}, nil 200 | } 201 | 202 | func (s *Server) PendingChecksByScheme(scheme string) ([]*PendingChecksInfo, error) { 203 | u := s.URL 204 | 205 | // The trailing '/' handling is really annoying. 206 | u.Path = path.Join(u.Path, "a/plugins/checks/checks.pending/") + "/" 207 | 208 | q := "scheme:" + scheme 209 | u.RawQuery = "query=" + q 210 | content, err := s.Get(&u) 211 | if err != nil { 212 | return nil, err 213 | } 214 | 215 | var out []*PendingChecksInfo 216 | if err := Unmarshal(content, &out); err != nil { 217 | return nil, err 218 | } 219 | 220 | return out, nil 221 | } 222 | 223 | // PendingChecks returns the checks pending for the given checker. 224 | func (s *Server) PendingChecks(checkerUUID string) ([]*PendingChecksInfo, error) { 225 | u := s.URL 226 | 227 | // The trailing '/' handling is really annoying. 228 | u.Path = path.Join(u.Path, "a/plugins/checks/checks.pending/") + "/" 229 | 230 | q := "checker:" + checkerUUID 231 | u.RawQuery = "query=" + url.QueryEscape(q) 232 | 233 | content, err := s.Get(&u) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | var out []*PendingChecksInfo 239 | if err := Unmarshal(content, &out); err != nil { 240 | return nil, err 241 | } 242 | 243 | return out, nil 244 | } 245 | 246 | // PostCheck posts a single check result onto a change. 247 | func (s *Server) PostCheck(changeID string, psID int, input *CheckInput) (*CheckInfo, error) { 248 | body, err := json.Marshal(input) 249 | if err != nil { 250 | return nil, err 251 | } 252 | 253 | res, err := s.PostPath(fmt.Sprintf("a/changes/%s/revisions/%d/checks/", changeID, psID), 254 | "application/json", body) 255 | if err != nil { 256 | return nil, err 257 | } 258 | 259 | var out CheckInfo 260 | if err := Unmarshal(res, &out); err != nil { 261 | return nil, err 262 | } 263 | 264 | return &out, nil 265 | } 266 | -------------------------------------------------------------------------------- /gerrit/types.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gerrit 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | "time" 22 | ) 23 | 24 | var jsonPrefix = []byte(")]}'") 25 | 26 | type File struct { 27 | Status string 28 | LinesInserted int `json:"lines_inserted"` 29 | SizeDelta int `json:"size_delta"` 30 | Size int 31 | Content []byte 32 | } 33 | 34 | type Change struct { 35 | Files map[string]*File 36 | } 37 | 38 | type CheckerInput struct { 39 | UUID string `json:"uuid"` 40 | Name string `json:"name"` 41 | Description string `json:"description"` 42 | URL string `json:"url"` 43 | Repository string `json:"repository"` 44 | Status string `json:"status"` 45 | Blocking []string `json:"blocking"` 46 | Query string `json:"query"` 47 | } 48 | 49 | // Gerrit doesn't use the format with "T" in the middle, so must 50 | // define a custom serializer. 51 | 52 | const timeLayout = "2006-01-02 15:04:05.000000000" 53 | 54 | type Timestamp time.Time 55 | 56 | func (ts *Timestamp) String() string { 57 | return ((time.Time)(*ts)).String() 58 | } 59 | 60 | var _ = (json.Marshaler)((*Timestamp)(nil)) 61 | 62 | func (ts *Timestamp) MarshalJSON() ([]byte, error) { 63 | t := (*time.Time)(ts) 64 | return []byte("\"" + t.Format(timeLayout) + "\""), nil 65 | } 66 | 67 | var _ = (json.Unmarshaler)((*Timestamp)(nil)) 68 | 69 | func (ts *Timestamp) UnmarshalJSON(b []byte) error { 70 | b = bytes.TrimPrefix(b, []byte{'"'}) 71 | b = bytes.TrimSuffix(b, []byte{'"'}) 72 | t, err := time.Parse(timeLayout, string(b)) 73 | if err != nil { 74 | return err 75 | } 76 | *ts = Timestamp(t) 77 | return nil 78 | } 79 | 80 | type CheckerInfo struct { 81 | UUID string `json:"uuid"` 82 | Name string 83 | Description string 84 | URL string `json:"url"` 85 | Repository string `json:"repository"` 86 | Status string 87 | Blocking []string `json:"blocking"` 88 | Query string `json:"query"` 89 | Created Timestamp `json:"created"` 90 | Updated Timestamp `json:"updated"` 91 | } 92 | 93 | func (info *CheckerInfo) String() string { 94 | out, _ := json.Marshal(info) 95 | return string(out) 96 | } 97 | 98 | // Unmarshal unmarshals Gerrit JSON, stripping the security prefix. 99 | func Unmarshal(content []byte, dest interface{}) error { 100 | if !bytes.HasPrefix(content, jsonPrefix) { 101 | if len(content) > 100 { 102 | content = content[:100] 103 | } 104 | bodyStr := string(content) 105 | 106 | return fmt.Errorf("prefix %q not found, got %s", jsonPrefix, bodyStr) 107 | } 108 | 109 | content = bytes.TrimPrefix(content, []byte(jsonPrefix)) 110 | return json.Unmarshal(content, dest) 111 | } 112 | 113 | type PendingCheckInfo struct { 114 | State string 115 | } 116 | 117 | type CheckablePatchSetInfo struct { 118 | Repository string 119 | ChangeNumber int `json:"change_number"` 120 | PatchSetID int `json:"patch_set_id"` 121 | } 122 | 123 | func (in *CheckablePatchSetInfo) String() string { 124 | out, _ := json.Marshal(in) 125 | return string(out) 126 | } 127 | 128 | type PendingChecksInfo struct { 129 | PatchSet *CheckablePatchSetInfo `json:"patch_set"` 130 | PendingChecks map[string]*PendingCheckInfo `json:"pending_checks"` 131 | } 132 | 133 | func (info *PendingCheckInfo) String() string { 134 | out, _ := json.Marshal(info) 135 | return string(out) 136 | } 137 | 138 | type CheckInput struct { 139 | CheckerUUID string `json:"checker_uuid"` 140 | State string `json:"state"` 141 | Message string `json:"message"` 142 | URL string `json:"url"` 143 | Started *Timestamp `json:"started"` 144 | } 145 | 146 | func (in *CheckInput) String() string { 147 | out, _ := json.Marshal(in) 148 | return string(out) 149 | } 150 | 151 | type CheckInfo struct { 152 | Repository string `json:"repository"` 153 | ChangeNumber int `json:"change_number"` 154 | PatchSetID int `json:"patch_set_id"` 155 | CheckerUUID string `json:"checker_uuid"` 156 | State string `json:"state"` 157 | Message string `json:"message"` 158 | Started Timestamp `json:"started"` 159 | Finished Timestamp `json:"finished"` 160 | Created Timestamp `json:"created"` 161 | Updated Timestamp `json:"updated"` 162 | CheckerName string `json:"checker_name"` 163 | CheckerStatus string `json:"checker_status"` 164 | Blocking []string `json:"blocking"` 165 | } 166 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/gerrit-linter 2 | 3 | require ( 4 | github.com/bazelbuild/buildtools v0.0.0-20190405103555-895625218c56 // indirect 5 | github.com/fsnotify/fsnotify v1.4.7 // indirect 6 | github.com/golang/protobuf v1.3.1 // indirect 7 | golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67 // indirect 8 | ) 9 | 10 | go 1.13 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bazelbuild/buildtools v0.0.0-20190404153937-93253d6efaa9 h1:WHyHxJd9ZTyyXqlEuheY93v4cPJLBy0SIEshPCcK0xQ= 2 | github.com/bazelbuild/buildtools v0.0.0-20190404153937-93253d6efaa9/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU= 3 | github.com/bazelbuild/buildtools v0.0.0-20190405103555-895625218c56 h1:PD9jdAihEJPtB7Slxf9MXAsVVTJBR9DQznTE65Q/bZc= 4 | github.com/bazelbuild/buildtools v0.0.0-20190405103555-895625218c56/go.mod h1:5JP0TXzWDHXv8qvxRC4InIazwdyDseBDbzESUMKk1yU= 5 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 6 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 7 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 8 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 9 | github.com/google/slothfs v0.0.0-20170112234537-ecdd255f653d h1:ADHffp2KLaMypb4pG5pBJ8AezYRvGxQQ8vnH0E1K04c= 10 | github.com/google/slothfs v0.0.0-20170112234537-ecdd255f653d/go.mod h1:kzvK/MFjZSNdFgc1tCZML3E1nVvnB4/npSKEuvMoECU= 11 | github.com/google/slothfs v0.0.0-20171212170352-85e1ea13e2e1 h1:ZMkp7Df6FFzf7lXn3Yv/5/K9pLoQJXm0AxaQ2i2N0Hc= 12 | github.com/google/slothfs v0.0.0-20171212170352-85e1ea13e2e1/go.mod h1:kzvK/MFjZSNdFgc1tCZML3E1nVvnB4/npSKEuvMoECU= 13 | github.com/google/slothfs v0.0.0-20190417171004-6b42407d9230 h1:iBLrJ79cF90CZmpskySqhPvzrWr9njBYEsOZubXLZlc= 14 | github.com/google/slothfs v0.0.0-20190417171004-6b42407d9230/go.mod h1:kzvK/MFjZSNdFgc1tCZML3E1nVvnB4/npSKEuvMoECU= 15 | golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67 h1:1Fzlr8kkDLQwqMP8GxrhptBLqZG/EDpiATneiZHY998= 16 | golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. All rights reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gerritlinter 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io" 21 | "io/ioutil" 22 | "log" 23 | "os" 24 | "os/exec" 25 | "path/filepath" 26 | "regexp" 27 | "sort" 28 | "strings" 29 | ) 30 | 31 | // Formatter is a definition of a formatting engine 32 | type Formatter interface { 33 | // Format returns the files but formatted. All files are 34 | // assumed to have the same language. 35 | Format(in []File, outSink io.Writer) (out []FormattedFile, err error) 36 | } 37 | 38 | // FormatterConfig defines the mapping configurable 39 | type FormatterConfig struct { 40 | // Regex is the typical filename regexp to use 41 | Regex *regexp.Regexp 42 | 43 | // Query is used to filter inside Gerrit 44 | Query string 45 | 46 | // The formatter 47 | Formatter Formatter 48 | } 49 | 50 | // Formatters holds all the formatters supported 51 | var Formatters = map[string]*FormatterConfig{ 52 | "commitmsg": { 53 | Regex: regexp.MustCompile(`^/COMMIT_MSG$`), 54 | Formatter: &commitMsgFormatter{}, 55 | }, 56 | } 57 | 58 | func init() { 59 | // Add path to self to $PATH, for easy deployment. 60 | if exe, err := os.Executable(); err == nil { 61 | os.Setenv("PATH", filepath.Dir(exe)+":"+os.Getenv("PATH")) 62 | } 63 | 64 | gjf, err := exec.LookPath("google-java-format.jar") 65 | if err == nil { 66 | Formatters["java"] = &FormatterConfig{ 67 | Regex: regexp.MustCompile(`\.java$`), 68 | Query: "ext:java", 69 | Formatter: &toolFormatter{ 70 | bin: "java", 71 | args: []string{"-jar", gjf, "-i"}, 72 | }, 73 | } 74 | } else { 75 | log.Printf("LookPath google-java-format: %v PATH=%s", err, os.Getenv("PATH")) 76 | } 77 | 78 | bzl, err := exec.LookPath("buildifier") 79 | if err == nil { 80 | Formatters["bzl"] = &FormatterConfig{ 81 | Regex: regexp.MustCompile(`(\.bzl|/BUILD|^BUILD)$`), 82 | Query: "(ext:bzl OR file:BUILD OR file:WORKSPACE)", 83 | Formatter: &toolFormatter{ 84 | bin: bzl, 85 | args: []string{"-mode=fix"}, 86 | }, 87 | } 88 | } else { 89 | log.Printf("LookPath buildifier: %v, PATH=%s", err, os.Getenv("PATH")) 90 | } 91 | 92 | gofmt, err := exec.LookPath("gofmt") 93 | if err == nil { 94 | Formatters["go"] = &FormatterConfig{ 95 | Regex: regexp.MustCompile(`\.go$`), 96 | Query: "ext:go", 97 | Formatter: &toolFormatter{ 98 | bin: gofmt, 99 | args: []string{"-w"}, 100 | }, 101 | } 102 | } else { 103 | log.Printf("LookPath gofmt: %v, PATH=%s", err, os.Getenv("PATH")) 104 | } 105 | } 106 | 107 | // IsSupported returns if the given language is supported. 108 | func IsSupported(lang string) bool { 109 | _, ok := Formatters[lang] 110 | return ok 111 | } 112 | 113 | // SupportedLanguages returns a list of languages. 114 | func SupportedLanguages() []string { 115 | var r []string 116 | for l := range Formatters { 117 | r = append(r, l) 118 | } 119 | sort.Strings(r) 120 | return r 121 | } 122 | 123 | func splitByLang(in []File) map[string][]File { 124 | res := map[string][]File{} 125 | for _, f := range in { 126 | res[f.Language] = append(res[f.Language], f) 127 | } 128 | return res 129 | } 130 | 131 | // Format formats all the files in the request for which a formatter exists. 132 | func Format(req *FormatRequest, rep *FormatReply) error { 133 | for _, f := range req.Files { 134 | if f.Language == "" { 135 | return fmt.Errorf("file %q has empty language", f.Name) 136 | } 137 | } 138 | 139 | for language, fs := range splitByLang(req.Files) { 140 | var buf bytes.Buffer 141 | entry := Formatters[language] 142 | log.Println("init", Formatters) 143 | 144 | out, err := entry.Formatter.Format(fs, &buf) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | if len(out) > 0 && out[0].Message == "" { 150 | out[0].Message = buf.String() 151 | } 152 | rep.Files = append(rep.Files, out...) 153 | } 154 | return nil 155 | } 156 | 157 | type commitMsgFormatter struct{} 158 | 159 | func (f *commitMsgFormatter) Format(in []File, outSink io.Writer) (out []FormattedFile, err error) { 160 | complaint := checkCommitMessage(string(in[0].Content)) 161 | ff := FormattedFile{} 162 | ff.Name = in[0].Name 163 | if complaint != "" { 164 | ff.Message = complaint 165 | } else { 166 | ff.Content = in[0].Content 167 | } 168 | out = append(out, ff) 169 | return out, nil 170 | } 171 | 172 | func checkCommitMessage(msg string) (complaint string) { 173 | lines := strings.Split(msg, "\n") 174 | if len(lines) < 2 { 175 | return "must have multiple lines" 176 | } 177 | 178 | if len(lines[1]) > 1 { 179 | return "subject and body must be separated by blank line" 180 | } 181 | 182 | if len(lines[0]) > 70 { 183 | return "subject must be less than 70 chars" 184 | } 185 | 186 | if strings.HasSuffix(lines[0], ".") { 187 | return "subject must not end in '.'" 188 | } 189 | 190 | return "" 191 | } 192 | 193 | type toolFormatter struct { 194 | bin string 195 | args []string 196 | } 197 | 198 | func (f *toolFormatter) Format(in []File, outSink io.Writer) (out []FormattedFile, err error) { 199 | cmd := exec.Command(f.bin, f.args...) 200 | 201 | tmpDir, err := ioutil.TempDir("", "gerritfmt") 202 | if err != nil { 203 | return nil, err 204 | } 205 | defer os.RemoveAll(tmpDir) 206 | 207 | for _, f := range in { 208 | dir, base := filepath.Split(f.Name) 209 | dir = filepath.Join(tmpDir, dir) 210 | if err := os.MkdirAll(dir, 0755); err != nil { 211 | return nil, err 212 | } 213 | 214 | if err := ioutil.WriteFile(filepath.Join(dir, base), f.Content, 0644); err != nil { 215 | return nil, err 216 | } 217 | 218 | cmd.Args = append(cmd.Args, f.Name) 219 | } 220 | cmd.Dir = tmpDir 221 | 222 | var errBuf, outBuf bytes.Buffer 223 | cmd.Stdout = &outBuf 224 | cmd.Stderr = &errBuf 225 | log.Println("running", cmd.Args, "in", tmpDir) 226 | if err := cmd.Run(); err != nil { 227 | log.Printf("error %v, stderr %s, stdout %s", err, errBuf.String(), 228 | outBuf.String()) 229 | return nil, err 230 | } 231 | 232 | for _, f := range in { 233 | c, err := ioutil.ReadFile(filepath.Join(tmpDir, f.Name)) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | out = append(out, FormattedFile{ 239 | File: File{ 240 | Name: f.Name, 241 | Content: c, 242 | }, 243 | }) 244 | } 245 | 246 | return out, nil 247 | } 248 | -------------------------------------------------------------------------------- /vm-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eux 4 | 5 | t=$(basename $1 .tar.gz) 6 | 7 | tar fzx $t.tar.gz 8 | rm -f gerrit-linter 9 | ln -s $t gerrit-linter 10 | --------------------------------------------------------------------------------