├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── crbot │ ├── .gitignore │ └── crbot.go ├── common.mk ├── config ├── config.go └── config_test.go ├── ghutil ├── .gitignore ├── ghutil.go └── ghutil_test.go ├── go.mod ├── go.sum ├── go_mod_tidy_test.sh ├── gofmt_test.sh └── logging └── logging.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | indet_size = 1 14 | 15 | [*.py] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.sh] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [Makefile] 24 | indent_style = tab 25 | indet_size = 1 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 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 | name: "Build & test" 16 | 17 | on: 18 | push: 19 | branches: [ main ] 20 | pull_request: 21 | branches: [ main ] 22 | 23 | jobs: 24 | build: 25 | runs-on: ${{ matrix.os }} 26 | strategy: 27 | matrix: 28 | go: [ '1.19', '1.18', '1.17', '1.16' ] 29 | os: [ 'ubuntu-22.04', 'macos-12' ] 30 | name: Go ${{ matrix.go }} (${{ matrix.os }}) 31 | steps: 32 | - name: Checkout repo 33 | uses: actions/checkout@v2 34 | 35 | - name: Setup Go 36 | uses: actions/setup-go@v2 37 | with: 38 | go-version: ${{ matrix.go }} 39 | 40 | - name: Build 41 | run: go build -v ./... 42 | 43 | - name: Install dependencies for testing 44 | run: go install github.com/golang/mock/mockgen@v1.6.0 45 | 46 | - name: Generate mocks for testing 47 | run: go generate ./... 48 | 49 | - name: Run tests 50 | run: make test VERBOSE=1 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary editor files 2 | *.swp 3 | *~ 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute # 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | a just a few small guidelines you need to follow. 5 | 6 | 7 | ## Contributor License Agreement ## 8 | 9 | Contributions to any Google project must be accompanied by a Contributor 10 | License Agreement. This is not a copyright **assignment**, it simply gives 11 | Google permission to use and redistribute your contributions as part of the 12 | project. 13 | 14 | * If you are an individual writing original source code and you're sure you 15 | own the intellectual property, then you'll need to sign an [individual 16 | CLA][]. 17 | 18 | * If you work for a company that wants to allow you to contribute your work, 19 | then you'll need to sign a [corporate CLA][]. 20 | 21 | You generally only need to submit a CLA once, so if you've already submitted 22 | one (even if it was for a different project), you probably don't need to do it 23 | again. 24 | 25 | [individual CLA]: https://cla.developers.google.com/about/google-individual 26 | [corporate CLA]: https://cla.developers.google.com/about/google-corporate 27 | 28 | 29 | ## Submitting a patch ## 30 | 31 | 1. It's generally best to start by opening a new issue describing the bug or 32 | feature you're intending to fix. Even if you think it's relatively minor, 33 | it's helpful to know what people are working on. Mention in the initial 34 | issue that you are planning to work on that bug or feature so that it can 35 | be assigned to you. 36 | 37 | 1. Follow the normal process of [forking][] the project, and setup a new 38 | branch to work in. It's important that each group of changes be done in 39 | separate branches in order to ensure that a pull request only includes the 40 | commits related to that bug or feature. 41 | 42 | 1. Any significant changes should almost always be accompanied by tests. The 43 | project already has good test coverage, so look at some of the existing 44 | tests if you're unsure how to go about it. 45 | 46 | 1. All contributions must be licensed Apache 2.0 and all files must have 47 | a copy of the boilerplate licence comment which can be generated by the 48 | [autogen][] tool. 49 | 50 | 1. Do your best to have [well-formed commit messages][] for each change. 51 | This provides consistency throughout the project, and ensures that commit 52 | messages are able to be formatted properly by various git tools. 53 | 54 | 1. Finally, push the commits to your fork and submit a [pull request][]. 55 | 56 | [forking]: https://help.github.com/articles/fork-a-repo 57 | [autogen]: https://github.com/mbrukman/autogen 58 | [well-formed commit messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 59 | [pull request]: https://help.github.com/articles/creating-a-pull-request 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 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 | LEVEL = . 16 | include $(LEVEL)/common.mk 17 | 18 | go_test: 19 | $(VERB) echo 20 | $(VERB) echo "Running tests via 'go test' ..." 21 | $(VERB) go test -v ./... 22 | 23 | gofmt_test: 24 | $(VERB) echo 25 | $(VERB) echo "Running 'go fmt' test ..." 26 | $(VERB) ./gofmt_test.sh 27 | 28 | go_mod_tidy_test: 29 | $(VERB) echo "Running 'go mod tidy' test ..." 30 | $(VERB) ./go_mod_tidy_test.sh 31 | 32 | test: go_test gofmt_test go_mod_tidy_test 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Review Bot 2 | 3 | [![Build Status][github-ci-badge]][github-ci-url] 4 | [![Go Report Card][go-report-card-badge]][go-report-card-url] 5 | [![API docs][godoc-badge]][godoc-url] 6 | 7 | [github-ci-badge]: https://github.com/google/code-review-bot/actions/workflows/main.yml/badge.svg?branch=main 8 | [github-ci-url]: https://github.com/google/code-review-bot/actions/workflows/main.yml?query=branch%3Amain 9 | [go-report-card-badge]: https://goreportcard.com/badge/github.com/google/code-review-bot 10 | [go-report-card-url]: https://goreportcard.com/report/github.com/google/code-review-bot 11 | [godoc-badge]: https://img.shields.io/badge/godoc-reference-5272B4.svg 12 | [godoc-url]: https://godoc.org/github.com/google/code-review-bot 13 | 14 | ## Building 15 | 16 | To build the `crbot` tool without a cloned repo (assuming that `$GOPATH/bin` is 17 | in your `$PATH`): 18 | 19 | ```bash 20 | $ go install github.com/google/code-review-bot/cmd/crbot@latest 21 | $ crbot [options] 22 | ``` 23 | 24 | Or, from a cloned repo: 25 | 26 | ```bash 27 | $ git clone https://github.com/google/code-review-bot.git 28 | $ cd code-review-bot 29 | $ go build ./cmd/crbot 30 | $ ./crbot [options] 31 | ``` 32 | 33 | ## Developing 34 | 35 | Install the `mockgen` tool from [GoMock](https://github.com/golang/mock): 36 | 37 | ```bash 38 | $ go install github.com/golang/mock/mockgen@v1.6.0 39 | ``` 40 | 41 | Generate the mocks: 42 | 43 | ```bash 44 | $ go generate ./... 45 | ``` 46 | 47 | This specific version of the `mockgen` tool is what's used in this repo, and 48 | tests will fail if your version generates different code, including comments. 49 | 50 | To update the version of the tools used in this repo: 51 | 52 | 1. update the version number in this file (above) as well as in 53 | [`.github/workflows/main.yml`](.github/workflows/main.yml) and 54 | [`go.mod`](go.mod) (see entry for `github.com/golang/mock`) to match — 55 | note that you should use the same version for `mockgen` as for the 56 | `github.com/golang/mock` repo to ensure they're mutually-consistent 57 | 1. run `go mod tidy` to update the `go.sum` file 58 | 1. run the updated `go install` command above to get newer version of `mockgen` 59 | 1. run the `go generate` command above to regenerate the mocks 60 | 1. [run the tests](#testing) from the top-level of the tree 61 | 1. commit your changes to this file (`README.md`), `go.mod`, `go.sum`, and 62 | `main.yml`, making sure that the build passes on GitHub Actions before 63 | merging the change 64 | 65 | ## Testing 66 | 67 | Just what you might expect: 68 | 69 | ```bash 70 | $ make test 71 | ``` 72 | 73 | ## Contributing 74 | 75 | See [`CONTRIBUTING.md`](CONTRIBUTING.md) for more details. 76 | 77 | ## License 78 | 79 | Apache 2.0; see [`LICENSE`](LICENSE) for more details. 80 | 81 | ## Disclaimer 82 | 83 | This project is not an official Google project. It is not supported by Google 84 | and Google specifically disclaims all warranties as to its quality, 85 | merchantability, or fitness for a particular purpose. 86 | -------------------------------------------------------------------------------- /cmd/crbot/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore binary if created via `go build .` 2 | crbot 3 | -------------------------------------------------------------------------------- /cmd/crbot/crbot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 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 | "flag" 20 | "fmt" 21 | "os" 22 | "path" 23 | "strconv" 24 | "strings" 25 | 26 | "golang.org/x/oauth2" 27 | 28 | "github.com/google/code-review-bot/config" 29 | "github.com/google/code-review-bot/ghutil" 30 | "github.com/google/code-review-bot/logging" 31 | ) 32 | 33 | func main() { 34 | secretsFileFlag := flag.String("secrets", "", "Path to secrets file; required") 35 | configFileFlag := flag.String("config", "", "Path to config file; optional") 36 | claSignersFileFlag := flag.String("cla-signers", "", "Path to CLA signers; required") 37 | orgFlag := flag.String("org", "", "Name of organization or username; required if not set in config file") 38 | repoFlag := flag.String("repo", "", "Name of repo; if empty, implies all repos in org") 39 | prFlag := flag.String("pr", "", "Comma-separated list of PRs to process") 40 | updateRepoFlag := flag.Bool("update-repo", false, "Update labels on the repo") 41 | 42 | flag.Usage = func() { 43 | fmt.Fprintf(os.Stderr, "Syntax: %s [flags]\n\nFlags:\n", path.Base(os.Args[0])) 44 | flag.PrintDefaults() 45 | fmt.Fprintf(os.Stderr, "\nNote: -cla-signers, -config and -secrets accept YAML and JSON files.\n") 46 | } 47 | 48 | flag.Parse() 49 | 50 | if *secretsFileFlag == "" { 51 | logging.Fatalf("-secrets flag is required") 52 | } else if *claSignersFileFlag == "" { 53 | logging.Fatalf("-cla-signers flag is required") 54 | } 55 | 56 | // Read and parse required auth, config, and CLA signers files. 57 | secrets := config.ParseSecrets(*secretsFileFlag) 58 | cfg := config.ParseConfig(*configFileFlag) 59 | claSigners := config.ParseClaSigners(*claSignersFileFlag) 60 | 61 | // Get the org name from command-line flags or config file. 62 | var orgName string 63 | if *orgFlag != "" { 64 | orgName = *orgFlag 65 | } else if cfg.Org != "" { 66 | orgName = cfg.Org 67 | } else { 68 | logging.Fatalf("-org must be non-empty or `org` must be specified in config file") 69 | } 70 | 71 | // Get the repo name from command-line flags or config file. 72 | repoName := *repoFlag 73 | if repoName == "" { 74 | repoName = cfg.Repo 75 | } 76 | 77 | prNumbers := make([]int, 0) 78 | if *prFlag != "" { 79 | prElements := strings.Split(*prFlag, ",") 80 | prNumbers = make([]int, len(prElements)) 81 | for idx, elt := range prElements { 82 | num, err := strconv.ParseInt(elt, 10, 32) 83 | if err != nil { 84 | logging.Fatalf("Invalid value for flag -pr: %s", *prFlag) 85 | } 86 | prNumbers[idx] = int(num) 87 | } 88 | } 89 | 90 | // Configure authentication and connect to GitHub. 91 | ts := oauth2.StaticTokenSource( 92 | &oauth2.Token{AccessToken: secrets.Auth}, 93 | ) 94 | tc := oauth2.NewClient(context.Background(), ts) 95 | 96 | // Process org and repo(s) specified on the command-line. 97 | ghc := ghutil.NewClient(tc) 98 | repoSpec := ghutil.GitHubProcessOrgRepoSpec{ 99 | Org: orgName, 100 | Repo: repoName, 101 | Pulls: prNumbers, 102 | UpdateRepo: *updateRepoFlag, 103 | UnknownAsExternal: cfg.UnknownAsExternal, 104 | } 105 | ghc.ProcessOrgRepo(ghc, repoSpec, claSigners) 106 | } 107 | -------------------------------------------------------------------------------- /common.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 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 | VERB = @ 16 | ifeq ($(VERBOSE),1) 17 | VERB = 18 | endif 19 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 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 config 16 | 17 | import ( 18 | "encoding/json" 19 | "errors" 20 | "io/ioutil" 21 | "strings" 22 | 23 | "github.com/go-yaml/yaml" 24 | 25 | "github.com/google/code-review-bot/logging" 26 | ) 27 | 28 | // Secrets contains the authentication credentials for interacting with GitHub. 29 | type Secrets struct { 30 | Auth string `json:"auth" yaml:"auth"` 31 | } 32 | 33 | // Config is the configuration for the `crbot` tool to specify the scope at 34 | // which it should run, whether for all repos in a single organization, or a 35 | // single specific repo. 36 | type Config struct { 37 | Org string `json:"org,omitempty" yaml:"org,omitempty"` 38 | Repo string `json:"repo,omitempty" yaml:"repo,omitempty"` 39 | UnknownAsExternal bool `json:"unknown_as_external,omitempty" yaml:"unknown_as_external,omitempty"` 40 | } 41 | 42 | // Account represents a single user record, whether human or a bot, with a name, 43 | // email, and GitHub login. 44 | type Account struct { 45 | Name string `json:"name" yaml:"name"` 46 | Email string `json:"email" yaml:"email"` 47 | Login string `json:"github" yaml:"github"` 48 | } 49 | 50 | // Company represents a company record with a name, (optional) domain name(s), 51 | // and user accounts. 52 | type Company struct { 53 | Name string `json:"name" yaml:"name"` 54 | Domains []string `json:"domains,omitempty" yaml:"domains,omitempty"` 55 | People []Account `json:"people" yaml:"people"` 56 | } 57 | 58 | // ExternalClaSigners represents CLA signers managed by an external process, 59 | // i.e., not covered by this tool. This is useful for handling migrations into 60 | // or out of the system provided by Code Review Bot. 61 | type ExternalClaSigners struct { 62 | People []Account `json:"people,omitempty" yaml:"people,omitempty"` 63 | Bots []Account `json:"bots,omitempty" yaml:"bots,omitempty"` 64 | Companies []Company `json:"companies,omitempty" yaml:"companies,omitempty"` 65 | } 66 | 67 | // ClaSigners provides the overall structure of the CLA config: individual CLA 68 | // signers, bots, and corporate CLA signers. 69 | type ClaSigners struct { 70 | People []Account `json:"people,omitempty" yaml:"people,omitempty"` 71 | Bots []Account `json:"bots,omitempty" yaml:"bots,omitempty"` 72 | Companies []Company `json:"companies,omitempty" yaml:"companies,omitempty"` 73 | External *ExternalClaSigners `json:"external,omitempty" yaml:"external,omitempty"` 74 | } 75 | 76 | // parseFile is a helper method for parsing any of the YAML or JSON files we 77 | // need to load: secrets, config, or CLA signers. 78 | func parseFile(filetype string, filename string, data interface{}) { 79 | fileContents, err := ioutil.ReadFile(filename) 80 | if err != nil { 81 | logging.Fatalf("Error reading %s file '%s': %s", filetype, filename, err) 82 | } 83 | 84 | if strings.HasSuffix(filename, ".json") { 85 | err = json.Unmarshal(fileContents, data) 86 | } else if strings.HasSuffix(filename, ".yaml") || strings.HasSuffix(filename, ".yml") { 87 | err = yaml.Unmarshal(fileContents, data) 88 | } else { 89 | err = errors.New("unsupported file type; accepted: *.json, *.yaml, *.yml") 90 | } 91 | 92 | if err != nil { 93 | logging.Fatalf("Error parsing %s file '%s': %s", filetype, filename, err) 94 | } 95 | } 96 | 97 | // ParseSecrets parses the secrets (including auth tokens) from a YAML or JSON file. 98 | func ParseSecrets(filename string) Secrets { 99 | var secrets Secrets 100 | parseFile("secrets", filename, &secrets) 101 | return secrets 102 | } 103 | 104 | // ParseConfig parses the config from a YAML or JSON file. 105 | func ParseConfig(filename string) Config { 106 | var config Config 107 | // This config file is optional, so we shouldn't fail if the filename 108 | // is an empty string, but just return an uninitialized Config struct. 109 | if filename != "" { 110 | parseFile("config", filename, &config) 111 | } 112 | return config 113 | } 114 | 115 | // ParseClaSigners parses the CLA signers config from a YAML or JSON file. 116 | func ParseClaSigners(filename string) ClaSigners { 117 | var claSigners ClaSigners 118 | parseFile("CLA signers", filename, &claSigners) 119 | return claSigners 120 | } 121 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google Inc. 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 config 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/go-yaml/yaml" 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | func parseClaSigners(t *testing.T, claYaml string, claSigners *ClaSigners) { 25 | err := yaml.Unmarshal([]byte(claYaml), claSigners) 26 | if err != nil { 27 | t.Logf("Error parsing YAML: %v", err) 28 | t.Fail() 29 | } 30 | } 31 | 32 | func TestParseClaSignersEmpty(t *testing.T) { 33 | var claSigners ClaSigners 34 | parseClaSigners(t, "", &claSigners) 35 | assert.Equal(t, 0, len(claSigners.People)) 36 | assert.Equal(t, 0, len(claSigners.Bots)) 37 | assert.Equal(t, 0, len(claSigners.Companies)) 38 | assert.Nil(t, claSigners.External) 39 | } 40 | 41 | func TestParseClaSignersSimple(t *testing.T) { 42 | claYaml := ` 43 | people: 44 | - name: First Last 45 | email: first@example.com 46 | github: first-last 47 | ` 48 | var claSigners ClaSigners 49 | parseClaSigners(t, claYaml, &claSigners) 50 | assert.Equal(t, 1, len(claSigners.People), "Should have exactly 1 entry in the `people` section") 51 | person := claSigners.People[0] 52 | assert.Equal(t, "First Last", person.Name) 53 | assert.Equal(t, "first@example.com", person.Email) 54 | assert.Equal(t, "first-last", person.Login) 55 | 56 | assert.Equal(t, 0, len(claSigners.Bots)) 57 | assert.Equal(t, 0, len(claSigners.Companies)) 58 | assert.Nil(t, claSigners.External) 59 | } 60 | 61 | func TestParseClaSignersWithExternalNamed(t *testing.T) { 62 | claYaml := ` 63 | people: 64 | - name: First Last 65 | email: first@example.com 66 | github: first-last 67 | 68 | external: 69 | people: 70 | - name: User Name 71 | email: user@name.example 72 | github: user-name 73 | ` 74 | var claSigners ClaSigners 75 | parseClaSigners(t, claYaml, &claSigners) 76 | assert.Equal(t, 1, len(claSigners.People), "Should have exactly 1 entry in the `people` section") 77 | person := claSigners.People[0] 78 | assert.Equal(t, "First Last", person.Name) 79 | assert.Equal(t, "first@example.com", person.Email) 80 | assert.Equal(t, "first-last", person.Login) 81 | 82 | assert.Equal(t, 0, len(claSigners.Bots)) 83 | assert.Equal(t, 0, len(claSigners.Companies)) 84 | assert.NotNil(t, claSigners.External) 85 | 86 | external := claSigners.External 87 | assert.Equal(t, 1, len(external.People), "Should have exactly 1 entry in the external `people` section") 88 | extPerson := external.People[0] 89 | assert.Equal(t, "User Name", extPerson.Name) 90 | assert.Equal(t, "user@name.example", extPerson.Email) 91 | assert.Equal(t, "user-name", extPerson.Login) 92 | assert.Equal(t, 0, len(external.Bots)) 93 | assert.Equal(t, 0, len(external.Companies)) 94 | } 95 | -------------------------------------------------------------------------------- /ghutil/.gitignore: -------------------------------------------------------------------------------- 1 | mock_ghutil.go 2 | -------------------------------------------------------------------------------- /ghutil/ghutil.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 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 | // TODO(mbrukman): in the future, consider using the recently-added 16 | // `-copyright_filename` flag: https://github.com/golang/mock/pull/234 17 | // 18 | //go:generate mockgen -source ghutil.go -destination mock_ghutil.go -package ghutil -self_package github.com/google/code-review-bot/ghutil 19 | 20 | // Package ghutil provides utility methods for determining CLA compliance of 21 | // pull requests on GitHub repositories, and adding/removing labels and 22 | // comments. 23 | package ghutil 24 | 25 | import ( 26 | "context" 27 | "fmt" 28 | "net/http" 29 | "strings" 30 | 31 | "github.com/google/go-github/v21/github" 32 | 33 | "github.com/google/code-review-bot/config" 34 | "github.com/google/code-review-bot/logging" 35 | ) 36 | 37 | // The CLA-related labels we expect to be predefined on a given repository. 38 | const ( 39 | LabelClaYes = "cla: yes" 40 | LabelClaNo = "cla: no" 41 | LabelClaExternal = "cla: external" 42 | ) 43 | 44 | // OrganizationsService is the subset of `github.OrganizationsService` used by 45 | // this module. 46 | type OrganizationsService interface { 47 | } 48 | 49 | // RepositoriesService is the subset of `github.RepositoriesService` used by 50 | // this module. 51 | type RepositoriesService interface { 52 | Get(ctx context.Context, owner string, repo string) (*github.Repository, *github.Response, error) 53 | List(ctx context.Context, user string, opt *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) 54 | } 55 | 56 | // IssuesService is the subset of `github.IssuesService` used by this module. 57 | type IssuesService interface { 58 | AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) 59 | CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) 60 | GetLabel(ctx context.Context, owner string, repo string, name string) (*github.Label, *github.Response, error) 61 | ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) 62 | RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) 63 | } 64 | 65 | // PullRequestsService is the subset of `github.PullRequestsService` used by 66 | // this module. 67 | type PullRequestsService interface { 68 | List(ctx context.Context, owner string, repo string, opt *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) 69 | ListCommits(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) 70 | Get(ctx context.Context, owner string, repo string, number int) (*github.PullRequest, *github.Response, error) 71 | } 72 | 73 | // GitHubUtilApi is the locally-defined API for interfacing with GitHub, using 74 | // the methods in GitHubClient. 75 | type GitHubUtilApi interface { 76 | GetAllRepos(*GitHubClient, string, string) []*github.Repository 77 | CheckPullRequestCompliance(*GitHubClient, GitHubProcessSinglePullSpec, config.ClaSigners) (PullRequestStatus, error) 78 | ProcessPullRequest(*GitHubClient, GitHubProcessSinglePullSpec, config.ClaSigners, RepoClaLabelStatus) error 79 | ProcessOrgRepo(*GitHubClient, GitHubProcessOrgRepoSpec, config.ClaSigners) 80 | GetIssueClaLabelStatus(*GitHubClient, string, string, int) IssueClaLabelStatus 81 | GetRepoClaLabelStatus(*GitHubClient, string, string) RepoClaLabelStatus 82 | } 83 | 84 | // GitHubClient provides an interface to the GitHub APIs used in this module. 85 | type GitHubClient struct { 86 | // Note: we can't simply use `*GitHubUtilApi` to import all the 87 | // interface methods here, as they will not be assignable fields and 88 | // compile will error out with: 89 | // 90 | // cannot use promoted field GitHubUtilApi.GetAllRepos in struct literal of type GitHubClient 91 | // 92 | // for each of the methods listed here. 93 | GetAllRepos func(*GitHubClient, string, string) []*github.Repository 94 | CheckPullRequestCompliance func(*GitHubClient, GitHubProcessSinglePullSpec, config.ClaSigners) (PullRequestStatus, error) 95 | ProcessPullRequest func(*GitHubClient, GitHubProcessSinglePullSpec, config.ClaSigners, RepoClaLabelStatus) error 96 | ProcessOrgRepo func(*GitHubClient, GitHubProcessOrgRepoSpec, config.ClaSigners) 97 | GetIssueClaLabelStatus func(*GitHubClient, string, string, int) IssueClaLabelStatus 98 | GetRepoClaLabelStatus func(*GitHubClient, string, string) RepoClaLabelStatus 99 | 100 | Organizations OrganizationsService 101 | Repositories RepositoriesService 102 | Issues IssuesService 103 | PullRequests PullRequestsService 104 | } 105 | 106 | // GitHubProcessOrgRepoSpec is the specification of the work to be done for an 107 | // organization and repo (possibly multiple PRs). 108 | type GitHubProcessOrgRepoSpec struct { 109 | Org string 110 | Repo string 111 | Pulls []int 112 | UpdateRepo bool 113 | UnknownAsExternal bool 114 | } 115 | 116 | // GitHubProcessSinglePullSpec is the specification of work to be processed for 117 | // a single PR, carrying over the rest of the configuration settings from 118 | // GitHubProcessOrgRepoSpec. 119 | type GitHubProcessSinglePullSpec struct { 120 | Org string 121 | Repo string 122 | Pull *github.PullRequest 123 | UpdateRepo bool 124 | UnknownAsExternal bool 125 | } 126 | 127 | // NewClient creates a client to work with the GitHub API. 128 | func NewClient(tc *http.Client) *GitHubClient { 129 | client := github.NewClient(tc) 130 | client.UserAgent = "cla-helper" 131 | 132 | ghc := NewBasicClient() 133 | ghc.Organizations = client.Organizations 134 | ghc.PullRequests = client.PullRequests 135 | ghc.Issues = client.Issues 136 | ghc.Repositories = client.Repositories 137 | 138 | return ghc 139 | } 140 | 141 | // NewBasicClient returns a new client with only local methods bound; no 142 | // external methods (which require an `http.Client`) are available so this 143 | // client is only partially-constructed and can be used either in production 144 | // with additional bindings added in `NewClient` or for testing by assigning 145 | // mocked methods for the other services. 146 | func NewBasicClient() *GitHubClient { 147 | ghc := GitHubClient{ 148 | GetAllRepos: getAllRepos, 149 | CheckPullRequestCompliance: checkPullRequestCompliance, 150 | ProcessPullRequest: processPullRequest, 151 | ProcessOrgRepo: processOrgRepo, 152 | GetIssueClaLabelStatus: getIssueClaLabelStatus, 153 | GetRepoClaLabelStatus: getRepoClaLabelStatus, 154 | } 155 | 156 | return &ghc 157 | } 158 | 159 | // AuthorLogin retrieves the author from a `RepositoryCommit`. 160 | // 161 | // Per go-github project docs in `github/repos_commits.go`: 162 | // 163 | // > RepositoryCommit represents a commit in a repo. 164 | // > Note that it's wrapping a Commit, so author/committer information is 165 | // > in two places, but contain different details about them: in 166 | // > RepositoryCommit "github details", in Commit - "git details". 167 | func AuthorLogin(c *github.RepositoryCommit) string { 168 | if c.Author != nil { 169 | if c.Author.Login != nil { 170 | return *c.Author.Login 171 | } 172 | } 173 | return "" 174 | } 175 | 176 | // CommitterLogin retrieves the committer from a `RepositoryCommit`. 177 | // 178 | // See also the docs for `AuthorLogin` for additional information. 179 | func CommitterLogin(c *github.RepositoryCommit) string { 180 | if c.Committer != nil { 181 | if c.Committer.Login != nil { 182 | return *c.Committer.Login 183 | } 184 | } 185 | return "" 186 | } 187 | 188 | // getAllRepos retrieves either a single repository (if `repoName` is non-empty) 189 | // or all repositories in an organization of `repoName` is empty. 190 | func getAllRepos(ghc *GitHubClient, orgName string, repoName string) []*github.Repository { 191 | ctx := context.Background() 192 | if repoName == "" { 193 | repos, _, err := ghc.Repositories.List(ctx, orgName, nil) 194 | if err != nil { 195 | logging.Fatalf("Error listing all repos in org %s: %s", orgName, err) 196 | } 197 | return repos 198 | } 199 | repo, _, err := ghc.Repositories.Get(ctx, orgName, repoName) 200 | if err != nil { 201 | logging.Fatalf("Error looking up %s/%s: %s", orgName, repoName, err) 202 | } 203 | return []*github.Repository{repo} 204 | } 205 | 206 | // RepoClaLabelStatus provides the availability of CLA-related labels in the repo. 207 | type RepoClaLabelStatus struct { 208 | HasYes bool 209 | HasNo bool 210 | HasExternal bool 211 | } 212 | 213 | // getRepoClaLabelStatus checks whether the given GitHub repo has the 214 | // CLA-related labels defined. 215 | func getRepoClaLabelStatus(ghc *GitHubClient, orgName string, repoName string) (repoClaLabelStatus RepoClaLabelStatus) { 216 | ctx := context.Background() 217 | repoHasLabel := func(labelName string) bool { 218 | label, _, err := ghc.Issues.GetLabel(ctx, orgName, repoName, labelName) 219 | return label != nil && err == nil 220 | } 221 | 222 | repoClaLabelStatus.HasYes = repoHasLabel(LabelClaYes) 223 | repoClaLabelStatus.HasNo = repoHasLabel(LabelClaNo) 224 | repoClaLabelStatus.HasExternal = repoHasLabel(LabelClaExternal) 225 | return 226 | } 227 | 228 | // IssueClaLabelStatus provides the settings of CLA-related labels for a 229 | // particular issue. 230 | type IssueClaLabelStatus struct { 231 | HasYes bool 232 | HasNo bool 233 | HasExternal bool 234 | } 235 | 236 | // getIssueClaLabelStatus computes the settings of CLA-related Labels for a 237 | // specific issue. 238 | func getIssueClaLabelStatus(ghc *GitHubClient, orgName string, repoName string, pullNumber int) (issueClaLabelStatus IssueClaLabelStatus) { 239 | ctx := context.Background() 240 | labels, _, err := ghc.Issues.ListLabelsByIssue(ctx, orgName, repoName, pullNumber, nil) 241 | if err != nil { 242 | logging.Errorf("Error listing labels for repo '%s/%s, PR %d: %v", orgName, repoName, pullNumber, err) 243 | return 244 | } 245 | for _, label := range labels { 246 | if strings.EqualFold(*label.Name, LabelClaYes) { 247 | issueClaLabelStatus.HasYes = true 248 | } else if strings.EqualFold(*label.Name, LabelClaNo) { 249 | issueClaLabelStatus.HasNo = true 250 | } else if strings.EqualFold(*label.Name, LabelClaExternal) { 251 | issueClaLabelStatus.HasExternal = true 252 | } 253 | } 254 | return 255 | } 256 | 257 | // CanonicalizeEmail returns a canonical version of the email address. For all 258 | // addresses, it will lowercase the email. For Gmail addresses, it will also 259 | // remove the periods in the email address, as those are ignored, and hence 260 | // "user.name@gmail.com" is equivalent to "username@gmail.com" . 261 | func CanonicalizeEmail(email string) string { 262 | email = strings.ToLower(email) 263 | gmailSuffixes := [...]string{"@gmail.com", "@googlemail.com"} 264 | for _, suffix := range gmailSuffixes { 265 | if strings.HasSuffix(email, suffix) { 266 | username := strings.TrimSuffix(email, suffix) 267 | username = strings.Replace(username, ".", "", -1) 268 | email = fmt.Sprintf("%s%s", username, suffix) 269 | } 270 | } 271 | return email 272 | } 273 | 274 | // MatchAccount returns whether the provided account matches any of the accounts 275 | // in the passed-in configuration for enforcing the CLA. 276 | func MatchAccount(account config.Account, accounts []config.Account) bool { 277 | for _, account2 := range accounts { 278 | if account.Name == account2.Name && 279 | CanonicalizeEmail(account.Email) == CanonicalizeEmail(account2.Email) && 280 | strings.EqualFold(account.Login, account2.Login) { 281 | return true 282 | } 283 | } 284 | return false 285 | } 286 | 287 | // CommitStatus provides a signal as to the CLA-compliance of a specific 288 | // commit. 289 | type CommitStatus struct { 290 | Compliant bool 291 | NonComplianceReason string 292 | External bool 293 | } 294 | 295 | // ProcessCommit processes a single commit and returns compliance status and 296 | // failure reason, if any. 297 | func ProcessCommit(commit *github.RepositoryCommit, claSigners config.ClaSigners) CommitStatus { 298 | logging.Infof(" - commit: %s", *commit.SHA) 299 | 300 | commitStatus := CommitStatus{ 301 | Compliant: true, 302 | External: false, 303 | } 304 | 305 | authorLogin := AuthorLogin(commit) 306 | committerLogin := CommitterLogin(commit) 307 | var authorName, authorEmail string 308 | var committerName, committerEmail string 309 | 310 | // Only Git information can be found here (name and email only). 311 | if commit.Commit != nil { 312 | if commit.Commit.Author != nil { 313 | commitAuthor := commit.Commit.Author 314 | if commitAuthor.Name != nil { 315 | authorName = *commitAuthor.Name 316 | } 317 | if commitAuthor.Email != nil { 318 | authorEmail = *commitAuthor.Email 319 | } 320 | } 321 | 322 | if commit.Commit.Committer != nil { 323 | commitCommitter := commit.Commit.Committer 324 | if commitCommitter.Name != nil { 325 | committerName = *commitCommitter.Name 326 | } 327 | if commitCommitter.Email != nil { 328 | committerEmail = *commitCommitter.Email 329 | } 330 | } 331 | } 332 | 333 | if authorName == "" || authorEmail == "" || authorLogin == "" { 334 | commitStatus.Compliant = false 335 | commitStatus.NonComplianceReason = "Please verify the author name, email, and GitHub username association are all correct and match CLA records." 336 | } 337 | 338 | if committerName == "" || committerEmail == "" || committerLogin == "" { 339 | commitStatus.Compliant = false 340 | commitStatus.NonComplianceReason = "Please verify the committer name, email, and GitHub username association are all correct and match CLA records." 341 | } 342 | 343 | // Assuming the commit is compliant thus far, verify that both the author 344 | // and committer (which could be the same person) have signed the CLA. 345 | if commitStatus.Compliant { 346 | authorClaMatchFound := false 347 | committerClaMatchFound := false 348 | 349 | author := config.Account{ 350 | Name: authorName, 351 | Email: authorEmail, 352 | Login: authorLogin, 353 | } 354 | 355 | committer := config.Account{ 356 | Name: committerName, 357 | Email: committerEmail, 358 | Login: committerLogin, 359 | } 360 | 361 | authorClaMatchFound = authorClaMatchFound || MatchAccount(author, claSigners.People) 362 | committerClaMatchFound = committerClaMatchFound || MatchAccount(committer, claSigners.People) 363 | committerClaMatchFound = committerClaMatchFound || MatchAccount(committer, claSigners.Bots) 364 | 365 | for _, company := range claSigners.Companies { 366 | authorClaMatchFound = authorClaMatchFound || MatchAccount(author, company.People) 367 | committerClaMatchFound = committerClaMatchFound || MatchAccount(committer, company.People) 368 | } 369 | 370 | if !authorClaMatchFound { 371 | commitStatus.NonComplianceReason = "Author of one or more commits is not listed as a CLA signer, either individual or as a member of an organization." 372 | } 373 | 374 | if !committerClaMatchFound { 375 | commitStatus.NonComplianceReason = "Committer of one or more commits is not listed as a CLA signer, either individual or as a member of an organization." 376 | } 377 | 378 | commitStatus.Compliant = commitStatus.Compliant && authorClaMatchFound && committerClaMatchFound 379 | } 380 | 381 | // Put it all together now for display. 382 | logging.Infof(" author: %s <%s>, GitHub: %s", authorName, authorEmail, authorLogin) 383 | logging.Infof(" committer: %s <%s>, GitHub: %s", committerName, committerEmail, committerLogin) 384 | return commitStatus 385 | } 386 | 387 | // PullRequestStatus provides the CLA status for the entire PR, which considers 388 | // all of the commits. In this case, any single commit being out of compliance 389 | // (or external) marks the entire PR as being out of compliance (or external). 390 | // The only way to have a fully-compliant PR is to have all commits on the PR 391 | // compliant. 392 | type PullRequestStatus struct { 393 | Compliant bool 394 | NonComplianceReason string 395 | External bool 396 | } 397 | 398 | // checkPullRequestCompliance reports the compliance status of a pull request, 399 | // considering each of the commits included in the pull request. 400 | func checkPullRequestCompliance(ghc *GitHubClient, prSpec GitHubProcessSinglePullSpec, claSigners config.ClaSigners) (PullRequestStatus, error) { 401 | ctx := context.Background() 402 | pullRequestStatus := PullRequestStatus{ 403 | Compliant: false, 404 | External: false, 405 | } 406 | 407 | pullNumber := *prSpec.Pull.Number 408 | 409 | // List all commits for this PR 410 | commits, _, err := ghc.PullRequests.ListCommits(ctx, prSpec.Org, prSpec.Repo, pullNumber, nil) 411 | if err != nil { 412 | logging.Error("Error finding all commits on PR", pullNumber) 413 | return pullRequestStatus, err 414 | } 415 | 416 | // Start off with the base case that the PR is compliant and disqualify it if 417 | // anything is amiss. 418 | pullRequestStatus.Compliant = true 419 | 420 | for _, commit := range commits { 421 | // Don't bother processing if either the author's or committer's CLA is managed 422 | // externally, as it will be picked up by another tool or bot. 423 | isExternal := IsExternal(commit, claSigners, prSpec.UnknownAsExternal) 424 | if isExternal { 425 | pullRequestStatus.External = true 426 | break 427 | } 428 | 429 | commitStatus := ProcessCommit(commit, claSigners) 430 | 431 | if commitStatus.Compliant { 432 | logging.Info(" compliant: true") 433 | } else { 434 | logging.Info(" compliant: false:", commitStatus.NonComplianceReason) 435 | pullRequestStatus.NonComplianceReason = commitStatus.NonComplianceReason 436 | pullRequestStatus.Compliant = false 437 | } 438 | } 439 | return pullRequestStatus, nil 440 | } 441 | 442 | // processPullRequest validates all the commits for a particular pull request, 443 | // and optionally adds/removes labels and comments on a pull request (if the PR 444 | // is non-compliant) to alert the code author and reviewers that they need to 445 | // hold off on reviewing thes changes until the relevant CLA has been signed. 446 | func processPullRequest(ghc *GitHubClient, prSpec GitHubProcessSinglePullSpec, claSigners config.ClaSigners, repoClaLabelStatus RepoClaLabelStatus) error { 447 | ctx := context.Background() 448 | 449 | orgName := prSpec.Org 450 | repoName := prSpec.Repo 451 | pull := prSpec.Pull 452 | updateRepo := prSpec.UpdateRepo 453 | 454 | logging.Infof("PR %d: %s", *pull.Number, *pull.Title) 455 | 456 | pullRequestStatus, err := ghc.CheckPullRequestCompliance(ghc, prSpec, claSigners) 457 | if err != nil { 458 | return err 459 | } 460 | 461 | issueClaLabelStatus := ghc.GetIssueClaLabelStatus(ghc, orgName, repoName, *pull.Number) 462 | logging.Infof(" CLA label status [%s]: %v, [%s]: %v, [%s]: %v", 463 | LabelClaYes, issueClaLabelStatus.HasYes, LabelClaNo, issueClaLabelStatus.HasNo, 464 | LabelClaExternal, issueClaLabelStatus.HasExternal) 465 | 466 | addLabel := func(label string) { 467 | logging.Infof(" Adding label [%s] to repo '%s/%s' PR %d...", label, orgName, repoName, *pull.Number) 468 | if updateRepo { 469 | _, _, err := ghc.Issues.AddLabelsToIssue(ctx, orgName, repoName, *pull.Number, []string{label}) 470 | if err != nil { 471 | logging.Errorf("Error adding label [%s] to repo '%s/%s' PR %d: %v", label, orgName, repoName, *pull.Number, err) 472 | } 473 | } else { 474 | logging.Info(" ... but -update-repo flag is disabled; skipping") 475 | } 476 | } 477 | 478 | removeLabel := func(label string) { 479 | logging.Infof(" Removing label [%s] from repo '%s/%s' PR %d...", label, orgName, repoName, *pull.Number) 480 | if updateRepo { 481 | _, err := ghc.Issues.RemoveLabelForIssue(ctx, orgName, repoName, *pull.Number, label) 482 | if err != nil { 483 | logging.Errorf(" Error removing label [%s] from repo '%s/%s' PR %d: %v", label, orgName, repoName, *pull.Number, err) 484 | } 485 | } else { 486 | logging.Info(" ... but -update-repo flag is disabled; skipping") 487 | } 488 | } 489 | 490 | addComment := func(comment string) { 491 | logging.Infof(" Adding comment to repo '%s/%s/ PR %d: %s", orgName, repoName, *pull.Number, comment) 492 | if updateRepo { 493 | issueComment := github.IssueComment{ 494 | Body: &comment, 495 | } 496 | _, _, err := ghc.Issues.CreateComment(ctx, orgName, repoName, *pull.Number, &issueComment) 497 | if err != nil { 498 | logging.Errorf(" Error leaving comment on PR %d: %v", *pull.Number, err) 499 | } 500 | } else { 501 | logging.Info(" ... but -update-repo flag is disabled; skipping") 502 | } 503 | } 504 | 505 | if pullRequestStatus.External { 506 | logging.Info(" PR has externally-managed CLA signer") 507 | 508 | if issueClaLabelStatus.HasExternal { 509 | logging.Infof(" PR already has [%s] label", LabelClaExternal) 510 | } else { 511 | logging.Infof(" PR doesn't have [%s] label, but should", LabelClaExternal) 512 | if repoClaLabelStatus.HasExternal { 513 | addLabel(LabelClaExternal) 514 | } 515 | } 516 | if issueClaLabelStatus.HasYes { 517 | removeLabel(LabelClaYes) 518 | } 519 | if issueClaLabelStatus.HasNo { 520 | removeLabel(LabelClaNo) 521 | } 522 | 523 | // No need to add any other CLA-related labels or comments to this PR. 524 | return nil 525 | } 526 | 527 | if issueClaLabelStatus.HasExternal { 528 | logging.Infof(" PR has [%s] label, but shouldn't", LabelClaExternal) 529 | removeLabel(LabelClaExternal) 530 | } else { 531 | logging.Infof(" PR doesn't have [%s] label, and shouldn't", LabelClaExternal) 532 | // Nothing to do here. 533 | } 534 | 535 | if pullRequestStatus.Compliant { 536 | logging.Info(" PR is CLA-compliant") 537 | } else { 538 | logging.Info(" PR is NOT CLA-compliant:", pullRequestStatus.NonComplianceReason) 539 | } 540 | 541 | // Add or remove [cla: yes] and [cla: no] labels, as appropriate. 542 | if pullRequestStatus.Compliant { 543 | // if PR has [cla: no] label, remove it. 544 | if issueClaLabelStatus.HasNo { 545 | removeLabel(LabelClaNo) 546 | } else { 547 | logging.Infof(" No action needed: [%s] label already missing", LabelClaNo) 548 | } 549 | // if PR doesn't have [cla: yes] label, add it. 550 | if !issueClaLabelStatus.HasYes { 551 | if repoClaLabelStatus.HasYes { 552 | addLabel(LabelClaYes) 553 | } 554 | } else { 555 | logging.Infof(" No action needed: [%s] label already added", LabelClaYes) 556 | } 557 | } else /* !pullRequestIsCompliant */ { 558 | shouldAddComment := false 559 | // if PR doesn't have [cla: no] label, add it. 560 | if !issueClaLabelStatus.HasNo { 561 | if repoClaLabelStatus.HasNo { 562 | addLabel(LabelClaNo) 563 | } 564 | shouldAddComment = true 565 | } else { 566 | logging.Infof(" No action needed: [%s] label already added", LabelClaNo) 567 | } 568 | // if PR has [cla: yes] label, remove it. 569 | if issueClaLabelStatus.HasYes { 570 | removeLabel(LabelClaYes) 571 | shouldAddComment = true 572 | } else { 573 | logging.Infof(" No action needed: [%s] label already missing", LabelClaYes) 574 | } 575 | 576 | if shouldAddComment { 577 | addComment(pullRequestStatus.NonComplianceReason) 578 | } 579 | } 580 | 581 | return nil 582 | } 583 | 584 | // IsExternal computes whether the given commit should be processed by this 585 | // tool, or if it should be covered by an external CLA management tool. 586 | func IsExternal(commit *github.RepositoryCommit, claSigners config.ClaSigners, unknownAsExternal bool) bool { 587 | var logins []string 588 | if authorLogin := AuthorLogin(commit); authorLogin != "" { 589 | logins = append(logins, authorLogin) 590 | } 591 | if committerLogin := CommitterLogin(commit); committerLogin != "" { 592 | logins = append(logins, committerLogin) 593 | } 594 | 595 | matchAny := func(logins []string, accounts []config.Account) bool { 596 | for _, username := range logins { 597 | for _, account := range accounts { 598 | if username == account.Login { 599 | return true 600 | } 601 | } 602 | } 603 | return false 604 | } 605 | 606 | matchAllWithRemainder := func(logins []string, accounts []config.Account) []string { 607 | remainder := make([]string, 0) 608 | for _, username := range logins { 609 | found := false 610 | for _, account := range accounts { 611 | if username == account.Login { 612 | found = true 613 | break 614 | } 615 | } 616 | if !found { 617 | remainder = append(remainder, username) 618 | } 619 | } 620 | return remainder 621 | } 622 | 623 | if claSigners.External != nil { 624 | external := claSigners.External 625 | if matchAny(logins, external.People) || 626 | matchAny(logins, external.Bots) { 627 | return true 628 | } 629 | 630 | for _, company := range external.Companies { 631 | if matchAny(logins, company.People) { 632 | return true 633 | } 634 | } 635 | } 636 | 637 | // If any of the logins don't match any of the CLA Signers *and* the 638 | // `unknownAsExternal` is true, then this is an externally-managed 639 | // contributor. 640 | remainder := matchAllWithRemainder(logins, claSigners.People) 641 | remainder = matchAllWithRemainder(remainder, claSigners.Bots) 642 | for _, company := range claSigners.Companies { 643 | remainder = matchAllWithRemainder(remainder, company.People) 644 | } 645 | 646 | return len(remainder) > 0 && unknownAsExternal 647 | } 648 | 649 | // processOrgRepo handles all PRs in specified repos in the organization or user 650 | // account. If `repoName` is empty, it processes all repos, if `repoName` is 651 | // non-empty, it processes the specified repo. 652 | func processOrgRepo(ghc *GitHubClient, repoSpec GitHubProcessOrgRepoSpec, claSigners config.ClaSigners) { 653 | ctx := context.Background() 654 | // Retrieve all repositories for the given organization or user. 655 | orgName := repoSpec.Org 656 | repos := ghc.GetAllRepos(ghc, orgName, repoSpec.Repo) 657 | 658 | // For repository, find all outstanding (non-closed / non-merged PRs) 659 | for _, repo := range repos { 660 | repoName := *repo.Name 661 | 662 | logging.Infof("Repo: %s/%s", orgName, repoName) 663 | 664 | var pulls []*github.PullRequest 665 | if len(repoSpec.Pulls) > 0 { 666 | for _, pullNumber := range repoSpec.Pulls { 667 | pullRequest, _, err := ghc.PullRequests.Get(ctx, orgName, repoName, pullNumber) 668 | if err == nil { 669 | pulls = append(pulls, pullRequest) 670 | } 671 | } 672 | } else { 673 | // Find all pull requests for the given repo, if not specified. 674 | retrievedPulls, _, err := ghc.PullRequests.List(ctx, orgName, repoName, nil) 675 | if err != nil { 676 | logging.Fatalf("Error listing pull requests for %s/%s: %s", orgName, repoName, err) 677 | } 678 | pulls = retrievedPulls 679 | } 680 | 681 | // Process each pull request for author & commiter CLA status. 682 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 683 | for _, pull := range pulls { 684 | prSpec := GitHubProcessSinglePullSpec{ 685 | Org: orgName, 686 | Repo: repoName, 687 | Pull: pull, 688 | UpdateRepo: repoSpec.UpdateRepo, 689 | UnknownAsExternal: repoSpec.UnknownAsExternal, 690 | } 691 | err := ghc.ProcessPullRequest(ghc, prSpec, claSigners, repoClaLabelStatus) 692 | if err != nil { 693 | logging.Errorf("Error processing PR %d: %s", *pull.Number, err) 694 | } 695 | } 696 | } 697 | } 698 | -------------------------------------------------------------------------------- /ghutil/ghutil_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 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 ghutil_test 16 | 17 | import ( 18 | "errors" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/golang/mock/gomock" 23 | "github.com/stretchr/testify/assert" 24 | 25 | "github.com/google/code-review-bot/config" 26 | "github.com/google/code-review-bot/ghutil" 27 | "github.com/google/go-github/v21/github" 28 | ) 29 | 30 | type MockGitHubClient struct { 31 | Organizations *ghutil.MockOrganizationsService 32 | PullRequests *ghutil.MockPullRequestsService 33 | Issues *ghutil.MockIssuesService 34 | Repositories *ghutil.MockRepositoriesService 35 | Api *ghutil.MockGitHubUtilApi 36 | } 37 | 38 | func NewMockGitHubClient(ghc *ghutil.GitHubClient, ctrl *gomock.Controller) *MockGitHubClient { 39 | mockGhc := &MockGitHubClient{ 40 | Organizations: ghutil.NewMockOrganizationsService(ctrl), 41 | PullRequests: ghutil.NewMockPullRequestsService(ctrl), 42 | Issues: ghutil.NewMockIssuesService(ctrl), 43 | Repositories: ghutil.NewMockRepositoriesService(ctrl), 44 | Api: ghutil.NewMockGitHubUtilApi(ctrl), 45 | } 46 | 47 | // Patch the original GitHubClient with our mock services. 48 | ghc.Organizations = mockGhc.Organizations 49 | ghc.PullRequests = mockGhc.PullRequests 50 | ghc.Issues = mockGhc.Issues 51 | ghc.Repositories = mockGhc.Repositories 52 | 53 | return mockGhc 54 | } 55 | 56 | // Common parameters used across most, if not all, tests. 57 | var ( 58 | ctrl *gomock.Controller 59 | ghc *ghutil.GitHubClient 60 | mockGhc *MockGitHubClient 61 | 62 | noLabel *github.Label = nil 63 | any = gomock.Any() 64 | ) 65 | 66 | const ( 67 | orgName = "org" 68 | repoName = "repo" 69 | pullNumber = 42 70 | ) 71 | 72 | func setUp(t *testing.T) { 73 | ctrl = gomock.NewController(t) 74 | ghc = ghutil.NewBasicClient() 75 | mockGhc = NewMockGitHubClient(ghc, ctrl) 76 | } 77 | 78 | func tearDown(_ *testing.T) { 79 | ctrl.Finish() 80 | } 81 | 82 | func TestGetAllRepos_OrgAndRepo(t *testing.T) { 83 | setUp(t) 84 | defer tearDown(t) 85 | 86 | repo := github.Repository{} 87 | 88 | mockGhc.Repositories.EXPECT().Get(any, orgName, repoName).Return(&repo, nil, nil) 89 | 90 | repos := ghc.GetAllRepos(ghc, orgName, repoName) 91 | assert.Equal(t, 1, len(repos), "repos is not of length 1: %v", repos) 92 | } 93 | 94 | func TestGetAllRepos_OrgOnly(t *testing.T) { 95 | setUp(t) 96 | defer tearDown(t) 97 | 98 | expectedRepos := []*github.Repository{ 99 | {}, 100 | {}, 101 | } 102 | 103 | mockGhc.Repositories.EXPECT().List(any, orgName, nil).Return(expectedRepos, nil, nil) 104 | 105 | actualRepos := ghc.GetAllRepos(ghc, orgName, "") 106 | assert.Equal(t, len(expectedRepos), len(actualRepos), "Expected repos: %v, actual repos: %v", expectedRepos, actualRepos) 107 | } 108 | 109 | func expectRepoLabels(orgName string, repoName string, hasYes bool, hasNo bool, hasExternal bool) { 110 | labels := map[string]bool{ 111 | ghutil.LabelClaYes: hasYes, 112 | ghutil.LabelClaNo: hasNo, 113 | ghutil.LabelClaExternal: hasExternal, 114 | } 115 | for label, exists := range labels { 116 | var ghLabel *github.Label 117 | if exists { 118 | ghLabel = &github.Label{} 119 | } 120 | mockGhc.Issues.EXPECT().GetLabel(any, orgName, repoName, label).Return(ghLabel, nil, nil) 121 | } 122 | } 123 | 124 | func TestVerifyRepoHasClaLabels_NoLabels(t *testing.T) { 125 | setUp(t) 126 | defer tearDown(t) 127 | 128 | expectRepoLabels(orgName, repoName, false, false, false) 129 | 130 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 131 | assert.False(t, repoClaLabelStatus.HasYes) 132 | assert.False(t, repoClaLabelStatus.HasNo) 133 | assert.False(t, repoClaLabelStatus.HasExternal) 134 | } 135 | 136 | func TestGetRepoClaLabelStatus_HasYesOnly(t *testing.T) { 137 | setUp(t) 138 | defer tearDown(t) 139 | 140 | expectRepoLabels(orgName, repoName, true, false, false) 141 | 142 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 143 | assert.True(t, repoClaLabelStatus.HasYes) 144 | assert.False(t, repoClaLabelStatus.HasNo) 145 | assert.False(t, repoClaLabelStatus.HasExternal) 146 | } 147 | 148 | func TestGetRepoClaLabelStatus_HasNoOnly(t *testing.T) { 149 | setUp(t) 150 | defer tearDown(t) 151 | 152 | expectRepoLabels(orgName, repoName, false, true, false) 153 | 154 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 155 | assert.False(t, repoClaLabelStatus.HasYes) 156 | assert.True(t, repoClaLabelStatus.HasNo) 157 | assert.False(t, repoClaLabelStatus.HasExternal) 158 | } 159 | 160 | func TestGetRepoClaLabelStatus_YesAndNoLabels(t *testing.T) { 161 | setUp(t) 162 | defer tearDown(t) 163 | 164 | expectRepoLabels(orgName, repoName, true, true, false) 165 | 166 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 167 | assert.True(t, repoClaLabelStatus.HasYes) 168 | assert.True(t, repoClaLabelStatus.HasNo) 169 | assert.False(t, repoClaLabelStatus.HasExternal) 170 | } 171 | 172 | func TestGetRepoClaLabelStatus_YesNoAndExternalLabels(t *testing.T) { 173 | setUp(t) 174 | defer tearDown(t) 175 | 176 | expectRepoLabels(orgName, repoName, true, true, true) 177 | 178 | repoClaLabelStatus := ghc.GetRepoClaLabelStatus(ghc, orgName, repoName) 179 | assert.True(t, repoClaLabelStatus.HasYes) 180 | assert.True(t, repoClaLabelStatus.HasNo) 181 | assert.True(t, repoClaLabelStatus.HasExternal) 182 | } 183 | 184 | func TestMatchAccount_MatchesCase(t *testing.T) { 185 | setUp(t) 186 | defer tearDown(t) 187 | 188 | // Credentials as provided by the user. 189 | account := config.Account{ 190 | Name: "Jane Doe", 191 | Email: "jane@example.com", 192 | Login: "JaneDoe", 193 | } 194 | 195 | // CLA as configured by the project. 196 | accounts := []config.Account{ 197 | { 198 | Name: "Jane Doe", 199 | Email: "jane@example.com", 200 | Login: "JaneDoe", 201 | }, 202 | } 203 | 204 | assert.True(t, ghutil.MatchAccount(account, accounts)) 205 | } 206 | 207 | func TestMatchAccount_DoesNotMatchCase(t *testing.T) { 208 | setUp(t) 209 | defer tearDown(t) 210 | 211 | // Credentials as provided by the user. 212 | account := config.Account{ 213 | Name: "Jane Doe", 214 | Email: "Jane.Doe@example.com", 215 | Login: "janedoe", 216 | } 217 | 218 | // CLA as configured by the project. 219 | accounts := []config.Account{ 220 | { 221 | Name: "Jane Doe", 222 | Email: "jane.doe@example.com", 223 | Login: "JaneDoe", 224 | }, 225 | } 226 | 227 | assert.True(t, ghutil.MatchAccount(account, accounts)) 228 | } 229 | 230 | func TestProcessCommit_DifferentAuthorAndCommitter(t *testing.T) { 231 | setUp(t) 232 | defer tearDown(t) 233 | 234 | name := "John Doe" 235 | corporateEmail := "john@github.com" 236 | personalEmail := "john@gmail.com" 237 | login := "johndoe" 238 | 239 | personal := config.Account{ 240 | Name: name, 241 | Email: personalEmail, 242 | Login: login, 243 | } 244 | corporate := config.Account{ 245 | Name: name, 246 | Email: corporateEmail, 247 | Login: login, 248 | } 249 | 250 | claSigners := config.ClaSigners{ 251 | Companies: []config.Company{ 252 | { 253 | Name: "Acme Inc.", 254 | People: []config.Account{corporate, personal}, 255 | }, 256 | }, 257 | } 258 | commit := createCommit(corporate, personal) 259 | commitStatus := ghutil.ProcessCommit(commit, claSigners) 260 | assert.True(t, commitStatus.Compliant, "Commit should have been marked compliant; reason: ", commitStatus.NonComplianceReason) 261 | } 262 | 263 | func TestProcessCommit_DifferentCaseInCommitEmailVsCLA(t *testing.T) { 264 | setUp(t) 265 | defer tearDown(t) 266 | 267 | userUC := config.Account{ 268 | Name: "User Name", 269 | Email: "User.Name@example.com", 270 | Login: "User-Name", 271 | } 272 | userLC := config.Account{ 273 | Name: userUC.Name, 274 | Email: strings.ToLower(userUC.Email), 275 | Login: strings.ToLower(userUC.Login), 276 | } 277 | 278 | claSigners := config.ClaSigners{ 279 | Companies: []config.Company{ 280 | { 281 | Name: "Acme, Inc.", 282 | People: []config.Account{userUC}, 283 | }, 284 | }, 285 | } 286 | 287 | commit := createCommit(userLC, userLC) 288 | commitStatus := ghutil.ProcessCommit(commit, claSigners) 289 | assert.True(t, commitStatus.Compliant, "Commit should have been marked compliant; reason: ", commitStatus.NonComplianceReason) 290 | } 291 | 292 | func TestCanonicalizeEmail_Gmail(t *testing.T) { 293 | setUp(t) 294 | defer tearDown(t) 295 | 296 | var goldenInputOutput = map[string]string{ 297 | "username@gmail.com": "username@gmail.com", 298 | "user.name@gmail.com": "username@gmail.com", 299 | "UserName@Gmail.Com": "username@gmail.com", 300 | "User.Name@Gmail.Com": "username@gmail.com", 301 | "U.s.e.r.N.a.m.e.@Gmail.Com": "username@gmail.com", 302 | "User.Name@example.com": "user.name@example.com", 303 | "User.Name@Example.com": "user.name@example.com", 304 | } 305 | 306 | for input, expected := range goldenInputOutput { 307 | assert.Equal(t, expected, ghutil.CanonicalizeEmail(input)) 308 | } 309 | } 310 | 311 | func TestGmailAddress_PeriodsDoNotMatchCLA(t *testing.T) { 312 | setUp(t) 313 | defer tearDown(t) 314 | 315 | var accountVsCla = map[string]string{ 316 | "jane.doe@gmail.com": "janedoe@gmail.com", 317 | "JaneDoe@gmail.com": "Jane.Doe@gmail.com", 318 | "janeDoe@gmail.com": "JaneDoe@gmail.com", 319 | "jane.doe@googlemail.com": "janedoe@googlemail.com", 320 | "JaneDoe@googlemail.com": "Jane.Doe@googlemail.com", 321 | "janeDoe@googlemail.com": "JaneDoe@googlemail.com", 322 | } 323 | 324 | for acctEmail, claEmail := range accountVsCla { 325 | // Credentials as provided by the user. 326 | account := config.Account{ 327 | Name: "Jane Doe", 328 | Email: acctEmail, 329 | Login: "janedoe", 330 | } 331 | 332 | // CLA as configured by the project. 333 | accounts := []config.Account{ 334 | { 335 | Name: "Jane Doe", 336 | Email: claEmail, 337 | Login: "janedoe", 338 | }, 339 | } 340 | 341 | assert.True(t, ghutil.MatchAccount(account, accounts)) 342 | } 343 | } 344 | 345 | func getSinglePullSpec() ghutil.GitHubProcessSinglePullSpec { 346 | localPullNumber := pullNumber 347 | localPullTitle := "no title" 348 | pull := github.PullRequest{ 349 | Number: &localPullNumber, 350 | Title: &localPullTitle, 351 | } 352 | 353 | return ghutil.GitHubProcessSinglePullSpec{ 354 | Org: orgName, 355 | Repo: repoName, 356 | Pull: &pull, 357 | } 358 | } 359 | 360 | func TestCheckPullRequestCompliance_ListCommitsError(t *testing.T) { 361 | setUp(t) 362 | defer tearDown(t) 363 | 364 | err := errors.New("Invalid PR") 365 | mockGhc.PullRequests.EXPECT().ListCommits(any, orgName, repoName, pullNumber, nil).Return(nil, nil, err) 366 | 367 | prSpec := getSinglePullSpec() 368 | claSigners := config.ClaSigners{} 369 | pullRequestStatus, retErr := ghc.CheckPullRequestCompliance(ghc, prSpec, claSigners) 370 | assert.False(t, pullRequestStatus.Compliant) 371 | assert.Equal(t, "", pullRequestStatus.NonComplianceReason) 372 | assert.Equal(t, err, retErr) 373 | } 374 | 375 | func createCommit(author config.Account, committer config.Account) *github.RepositoryCommit { 376 | // Uniqueness of SHA fingerprints for commits is not an invariant 377 | // that's required or enforced anywhere; we just need a non-null value 378 | // here, so it's OK to use the same value for all commits to avoid 379 | // dummy data in our test code. 380 | sha := "abc123def456" 381 | 382 | return &github.RepositoryCommit{ 383 | SHA: &sha, 384 | Commit: &github.Commit{ 385 | Author: &github.CommitAuthor{ 386 | Name: &author.Name, 387 | Email: &author.Email, 388 | }, 389 | Committer: &github.CommitAuthor{ 390 | Name: &committer.Name, 391 | Email: &committer.Email, 392 | }, 393 | }, 394 | Author: &github.User{ 395 | Login: &author.Login, 396 | }, 397 | Committer: &github.User{ 398 | Login: &committer.Login, 399 | }, 400 | } 401 | } 402 | 403 | func TestCheckPullRequestCompliance_OneCommitDifferentEmailCase(t *testing.T) { 404 | setUp(t) 405 | defer tearDown(t) 406 | 407 | userUC := config.Account{ 408 | Name: "User Name", 409 | Email: "User.Name@example.com", 410 | Login: "User-Name", 411 | } 412 | userLC := config.Account{ 413 | Name: userUC.Name, 414 | Email: strings.ToLower(userUC.Email), 415 | Login: strings.ToLower(userUC.Login), 416 | } 417 | 418 | commits := []*github.RepositoryCommit{ 419 | createCommit(userLC, userLC), 420 | } 421 | mockGhc.PullRequests.EXPECT().ListCommits(any, orgName, repoName, pullNumber, nil).Return(commits, nil, nil) 422 | 423 | prSpec := getSinglePullSpec() 424 | claSigners := config.ClaSigners{ 425 | Companies: []config.Company{ 426 | { 427 | Name: "Acme, Inc.", 428 | People: []config.Account{userUC}, 429 | }, 430 | }, 431 | } 432 | pullRequestStatus, err := ghc.CheckPullRequestCompliance(ghc, prSpec, claSigners) 433 | assert.True(t, pullRequestStatus.Compliant) 434 | assert.Equal(t, "", pullRequestStatus.NonComplianceReason) 435 | assert.Nil(t, err) 436 | } 437 | 438 | func TestCheckPullRequestCompliance_TwoCompliantCommits(t *testing.T) { 439 | setUp(t) 440 | defer tearDown(t) 441 | 442 | john, jane := createUserAccounts() 443 | 444 | commits := []*github.RepositoryCommit{ 445 | createCommit(john, john), 446 | createCommit(jane, jane), 447 | } 448 | mockGhc.PullRequests.EXPECT().ListCommits(any, orgName, repoName, pullNumber, nil).Return(commits, nil, nil) 449 | 450 | prSpec := getSinglePullSpec() 451 | claSigners := config.ClaSigners{ 452 | People: []config.Account{john, jane}, 453 | } 454 | pullRequestStatus, err := ghc.CheckPullRequestCompliance(ghc, prSpec, claSigners) 455 | assert.True(t, pullRequestStatus.Compliant) 456 | assert.Equal(t, "", pullRequestStatus.NonComplianceReason) 457 | assert.Nil(t, err) 458 | } 459 | 460 | func TestCheckPullRequestCompliance_OneCompliantOneNot(t *testing.T) { 461 | setUp(t) 462 | defer tearDown(t) 463 | 464 | john, jane := createUserAccounts() 465 | 466 | commits := []*github.RepositoryCommit{ 467 | createCommit(john, john), 468 | createCommit(jane, jane), 469 | } 470 | mockGhc.PullRequests.EXPECT().ListCommits(any, orgName, repoName, pullNumber, nil).Return(commits, nil, nil) 471 | 472 | prSpec := getSinglePullSpec() 473 | claSigners := config.ClaSigners{ 474 | People: []config.Account{john}, 475 | } 476 | pullRequestStatus, err := ghc.CheckPullRequestCompliance(ghc, prSpec, claSigners) 477 | assert.False(t, pullRequestStatus.Compliant) 478 | assert.Equal(t, "Committer of one or more commits is not listed as a CLA signer, either individual or as a member of an organization.", pullRequestStatus.NonComplianceReason) 479 | assert.Nil(t, err) 480 | } 481 | 482 | type ProcessPullRequest_TestParams struct { 483 | RepoClaLabelStatus ghutil.RepoClaLabelStatus 484 | IssueClaLabelStatus ghutil.IssueClaLabelStatus 485 | PullRequestStatus ghutil.PullRequestStatus 486 | UpdateRepo bool 487 | LabelsToAdd []string 488 | LabelsToRemove []string 489 | } 490 | 491 | func runProcessPullRequestTestScenario(t *testing.T, params ProcessPullRequest_TestParams) { 492 | // Dummy CLA signers data as we don't actually need to use it here, 493 | // since we're mocking out the functions that would otherwise process 494 | // this data. 495 | claSigners := config.ClaSigners{} 496 | 497 | prSpec := getSinglePullSpec() 498 | prSpec.UpdateRepo = params.UpdateRepo 499 | 500 | ghc.CheckPullRequestCompliance = mockGhc.Api.CheckPullRequestCompliance 501 | mockGhc.Api.EXPECT().CheckPullRequestCompliance(ghc, prSpec, claSigners).Return(params.PullRequestStatus, nil) 502 | 503 | ghc.GetIssueClaLabelStatus = mockGhc.Api.GetIssueClaLabelStatus 504 | mockGhc.Api.EXPECT().GetIssueClaLabelStatus(ghc, orgName, repoName, pullNumber).Return(params.IssueClaLabelStatus) 505 | 506 | if params.UpdateRepo { 507 | for _, label := range params.LabelsToAdd { 508 | mockGhc.Issues.EXPECT().AddLabelsToIssue(any, orgName, repoName, pullNumber, []string{label}).Return(nil, nil, nil) 509 | } 510 | 511 | for _, label := range params.LabelsToRemove { 512 | mockGhc.Issues.EXPECT().RemoveLabelForIssue(any, orgName, repoName, pullNumber, label).Return(nil, nil) 513 | } 514 | } 515 | 516 | err := ghc.ProcessPullRequest(ghc, prSpec, claSigners, params.RepoClaLabelStatus) 517 | assert.Nil(t, err) 518 | } 519 | 520 | func TestProcessPullRequest_RepoHasLabels_PullHasZeroLabels_Compliant_Update(t *testing.T) { 521 | setUp(t) 522 | defer tearDown(t) 523 | 524 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 525 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 526 | HasYes: true, 527 | HasNo: true, 528 | }, 529 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{}, 530 | PullRequestStatus: ghutil.PullRequestStatus{ 531 | Compliant: true, 532 | }, 533 | UpdateRepo: true, 534 | LabelsToAdd: []string{ghutil.LabelClaYes}, 535 | }) 536 | } 537 | 538 | func TestProcessPullRequest_RepoHasLabels_PullHasZeroLabels_NonCompliant_Update(t *testing.T) { 539 | setUp(t) 540 | defer tearDown(t) 541 | 542 | // When adding a "cla: no" label, we will also add a comment to the 543 | // effect of why this PR got that label. 544 | nonComplianceReason := "Your PR is not compliant" 545 | issueComment := github.IssueComment{ 546 | Body: &nonComplianceReason, 547 | } 548 | mockGhc.Issues.EXPECT().CreateComment(any, orgName, repoName, pullNumber, &issueComment).Return(nil, nil, nil) 549 | 550 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 551 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 552 | HasYes: true, 553 | HasNo: true, 554 | }, 555 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{}, 556 | PullRequestStatus: ghutil.PullRequestStatus{ 557 | Compliant: false, 558 | NonComplianceReason: nonComplianceReason, 559 | }, 560 | UpdateRepo: true, 561 | LabelsToAdd: []string{ghutil.LabelClaNo}, 562 | }) 563 | } 564 | 565 | func TestProcessPullRequest_RepoHasLabels_PullHasZeroLabels_External_Update(t *testing.T) { 566 | setUp(t) 567 | defer tearDown(t) 568 | 569 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 570 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 571 | HasYes: true, 572 | HasNo: true, 573 | HasExternal: true, 574 | }, 575 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{}, 576 | PullRequestStatus: ghutil.PullRequestStatus{ 577 | External: true, 578 | }, 579 | UpdateRepo: true, 580 | LabelsToAdd: []string{ghutil.LabelClaExternal}, 581 | }) 582 | } 583 | 584 | func TestProcessPullRequest_RepoHasHabels_PullHasYesLabel_Compliant(t *testing.T) { 585 | setUp(t) 586 | defer tearDown(t) 587 | 588 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 589 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 590 | HasYes: true, 591 | HasNo: true, 592 | }, 593 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 594 | HasYes: true, 595 | }, 596 | PullRequestStatus: ghutil.PullRequestStatus{ 597 | Compliant: true, 598 | }, 599 | UpdateRepo: true, 600 | // No labels to be added or removed in this case. 601 | }) 602 | } 603 | 604 | func TestProcessPullRequest_RepoHasLabels_PullHasYesLabel_NonCompliant(t *testing.T) { 605 | setUp(t) 606 | defer tearDown(t) 607 | 608 | // When adding a "cla: no" label, we will also add a comment to the 609 | // effect of why this PR got that label. 610 | nonComplianceReason := "Your PR is not compliant" 611 | issueComment := github.IssueComment{ 612 | Body: &nonComplianceReason, 613 | } 614 | mockGhc.Issues.EXPECT().CreateComment(any, orgName, repoName, pullNumber, &issueComment).Return(nil, nil, nil) 615 | 616 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 617 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 618 | HasYes: true, 619 | HasNo: true, 620 | }, 621 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 622 | HasYes: true, 623 | }, 624 | PullRequestStatus: ghutil.PullRequestStatus{ 625 | Compliant: false, 626 | NonComplianceReason: nonComplianceReason, 627 | }, 628 | UpdateRepo: true, 629 | LabelsToAdd: []string{ghutil.LabelClaNo}, 630 | LabelsToRemove: []string{ghutil.LabelClaYes}, 631 | }) 632 | } 633 | 634 | func TestProcessPullRequest_RepoHasYesNoExternalHabels_PullHasYesLabel_External(t *testing.T) { 635 | setUp(t) 636 | defer tearDown(t) 637 | 638 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 639 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 640 | HasYes: true, 641 | HasNo: true, 642 | HasExternal: true, 643 | }, 644 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 645 | HasYes: true, 646 | }, 647 | PullRequestStatus: ghutil.PullRequestStatus{ 648 | External: true, 649 | }, 650 | UpdateRepo: true, 651 | LabelsToAdd: []string{ghutil.LabelClaExternal}, 652 | LabelsToRemove: []string{ghutil.LabelClaYes}, 653 | }) 654 | } 655 | 656 | func TestProcessPullRequest_RepoHasYesNoHabels_PullHasYesLabel_External(t *testing.T) { 657 | setUp(t) 658 | defer tearDown(t) 659 | 660 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 661 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 662 | HasYes: true, 663 | HasNo: true, 664 | }, 665 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 666 | HasYes: true, 667 | }, 668 | PullRequestStatus: ghutil.PullRequestStatus{ 669 | External: true, 670 | }, 671 | UpdateRepo: true, 672 | // The external label wouldn't be added in this case, since the 673 | // repo doesn't have it. 674 | LabelsToRemove: []string{ghutil.LabelClaYes}, 675 | }) 676 | } 677 | 678 | func TestProcessPullRequest_RepoHasLabels_HasNoLabel_Compliant(t *testing.T) { 679 | setUp(t) 680 | defer tearDown(t) 681 | 682 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 683 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 684 | HasYes: true, 685 | HasNo: true, 686 | }, 687 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 688 | HasNo: true, 689 | }, 690 | PullRequestStatus: ghutil.PullRequestStatus{ 691 | Compliant: true, 692 | }, 693 | UpdateRepo: true, 694 | LabelsToAdd: []string{ghutil.LabelClaYes}, 695 | LabelsToRemove: []string{ghutil.LabelClaNo}, 696 | }) 697 | } 698 | 699 | func TestProcessPullRequest_RepoHasLabels_PullHasNoLabel_NonCompliant(t *testing.T) { 700 | setUp(t) 701 | defer tearDown(t) 702 | 703 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 704 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 705 | HasYes: true, 706 | HasNo: true, 707 | }, 708 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 709 | HasNo: true, 710 | }, 711 | PullRequestStatus: ghutil.PullRequestStatus{ 712 | Compliant: false, 713 | }, 714 | UpdateRepo: true, 715 | // No labels to be added or removed in this case. 716 | }) 717 | } 718 | 719 | func TestProcessPullRequest_RepoHasLabels_PullHasNoLabel_External(t *testing.T) { 720 | setUp(t) 721 | defer tearDown(t) 722 | 723 | runProcessPullRequestTestScenario(t, ProcessPullRequest_TestParams{ 724 | RepoClaLabelStatus: ghutil.RepoClaLabelStatus{ 725 | HasYes: true, 726 | HasNo: true, 727 | HasExternal: true, 728 | }, 729 | IssueClaLabelStatus: ghutil.IssueClaLabelStatus{ 730 | HasNo: true, 731 | }, 732 | PullRequestStatus: ghutil.PullRequestStatus{ 733 | External: true, 734 | }, 735 | UpdateRepo: true, 736 | LabelsToAdd: []string{ghutil.LabelClaExternal}, 737 | LabelsToRemove: []string{ghutil.LabelClaNo}, 738 | }) 739 | } 740 | 741 | func TestProcessOrgRepo_SpecifiedPrs(t *testing.T) { 742 | setUp(t) 743 | defer tearDown(t) 744 | 745 | localRepoName := repoName 746 | repos := []*github.Repository{ 747 | { 748 | Name: &localRepoName, 749 | }, 750 | } 751 | 752 | ghc.GetAllRepos = mockGhc.Api.GetAllRepos 753 | mockGhc.Api.EXPECT().GetAllRepos(ghc, orgName, repoName).Return(repos) 754 | 755 | pullNumber1 := 42 756 | pullTitle1 := "pull 42 title" 757 | pullRequest1 := github.PullRequest{ 758 | Number: &pullNumber1, 759 | Title: &pullTitle1, 760 | } 761 | pullNumber2 := 43 762 | pullTitle2 := "pull 43 title" 763 | pullRequest2 := github.PullRequest{ 764 | Number: &pullNumber2, 765 | Title: &pullTitle2, 766 | } 767 | mockGhc.PullRequests.EXPECT().Get(any, orgName, repoName, pullNumber1).Return(&pullRequest1, nil, nil) 768 | mockGhc.PullRequests.EXPECT().Get(any, orgName, repoName, pullNumber2).Return(&pullRequest2, nil, nil) 769 | 770 | repoClaLabelStatus := ghutil.RepoClaLabelStatus{} 771 | 772 | ghc.GetRepoClaLabelStatus = mockGhc.Api.GetRepoClaLabelStatus 773 | mockGhc.Api.EXPECT().GetRepoClaLabelStatus(ghc, orgName, repoName).Return(repoClaLabelStatus) 774 | 775 | claSigners := config.ClaSigners{} 776 | 777 | prSpec1 := ghutil.GitHubProcessSinglePullSpec{ 778 | Org: orgName, 779 | Repo: repoName, 780 | Pull: &pullRequest1, 781 | } 782 | prSpec2 := ghutil.GitHubProcessSinglePullSpec{ 783 | Org: orgName, 784 | Repo: repoName, 785 | Pull: &pullRequest2, 786 | } 787 | ghc.ProcessPullRequest = mockGhc.Api.ProcessPullRequest 788 | mockGhc.Api.EXPECT().ProcessPullRequest(ghc, prSpec1, claSigners, repoClaLabelStatus) 789 | mockGhc.Api.EXPECT().ProcessPullRequest(ghc, prSpec2, claSigners, repoClaLabelStatus) 790 | 791 | repoSpec := ghutil.GitHubProcessOrgRepoSpec{ 792 | Org: orgName, 793 | Repo: repoName, 794 | Pulls: []int{pullNumber1, pullNumber2}, 795 | } 796 | ghc.ProcessOrgRepo(ghc, repoSpec, claSigners) 797 | } 798 | 799 | func TestProcessOrgRepo_AllPrs(t *testing.T) { 800 | setUp(t) 801 | defer tearDown(t) 802 | 803 | localRepoName := repoName 804 | repos := []*github.Repository{ 805 | { 806 | Name: &localRepoName, 807 | }, 808 | } 809 | 810 | ghc.GetAllRepos = mockGhc.Api.GetAllRepos 811 | mockGhc.Api.EXPECT().GetAllRepos(ghc, orgName, repoName).Return(repos) 812 | 813 | pullNumber1 := 42 814 | pullTitle1 := "pull 42 title" 815 | pullNumber2 := 43 816 | pullTitle2 := "pull 43 title" 817 | pullRequests := []*github.PullRequest{ 818 | { 819 | Number: &pullNumber1, 820 | Title: &pullTitle1, 821 | }, 822 | { 823 | Number: &pullNumber2, 824 | Title: &pullTitle2, 825 | }, 826 | } 827 | mockGhc.PullRequests.EXPECT().List(any, orgName, repoName, nil).Return(pullRequests, nil, nil) 828 | 829 | repoClaLabelStatus := ghutil.RepoClaLabelStatus{} 830 | 831 | ghc.GetRepoClaLabelStatus = mockGhc.Api.GetRepoClaLabelStatus 832 | mockGhc.Api.EXPECT().GetRepoClaLabelStatus(ghc, orgName, repoName).Return(repoClaLabelStatus) 833 | 834 | claSigners := config.ClaSigners{} 835 | 836 | ghc.ProcessPullRequest = mockGhc.Api.ProcessPullRequest 837 | for _, pull := range pullRequests { 838 | prSpec := ghutil.GitHubProcessSinglePullSpec{ 839 | Org: orgName, 840 | Repo: repoName, 841 | Pull: pull, 842 | } 843 | mockGhc.Api.EXPECT().ProcessPullRequest(ghc, prSpec, claSigners, repoClaLabelStatus) 844 | } 845 | 846 | repoSpec := ghutil.GitHubProcessOrgRepoSpec{ 847 | Org: orgName, 848 | Repo: repoName, 849 | } 850 | ghc.ProcessOrgRepo(ghc, repoSpec, claSigners) 851 | } 852 | 853 | func createUserAccounts() (config.Account, config.Account) { 854 | john := config.Account{ 855 | Name: "John Doe", 856 | Email: "john@example.com", 857 | Login: "john-doe", 858 | } 859 | jane := config.Account{ 860 | Name: "Jane Doe", 861 | Email: "jane@example.com", 862 | Login: "jane-doe", 863 | } 864 | return john, jane 865 | } 866 | 867 | func TestIsExternal_JustJohnInPeople(t *testing.T) { 868 | setUp(t) 869 | defer tearDown(t) 870 | 871 | john, jane := createUserAccounts() 872 | 873 | claSigners := config.ClaSigners{ 874 | People: []config.Account{ 875 | john, 876 | }, 877 | } 878 | 879 | commits := []*github.RepositoryCommit{ 880 | createCommit(john, jane), 881 | } 882 | 883 | for _, commit := range commits { 884 | assert.False(t, ghutil.IsExternal(commit, claSigners, false), 885 | "commit should not be considered external: %v", *commit) 886 | } 887 | } 888 | 889 | func TestIsExternal_JohnAndJaneInPeople(t *testing.T) { 890 | setUp(t) 891 | defer tearDown(t) 892 | 893 | john, jane := createUserAccounts() 894 | 895 | claSigners := config.ClaSigners{ 896 | People: []config.Account{ 897 | john, 898 | jane, 899 | }, 900 | } 901 | 902 | commits := []*github.RepositoryCommit{ 903 | createCommit(john, john), 904 | createCommit(john, jane), 905 | createCommit(jane, john), 906 | createCommit(jane, jane), 907 | } 908 | 909 | for _, commit := range commits { 910 | assert.False(t, ghutil.IsExternal(commit, claSigners, false), 911 | "commit should not be considered external: %v", *commit) 912 | } 913 | } 914 | 915 | func TestIsExternal_JaneIsABot(t *testing.T) { 916 | setUp(t) 917 | defer tearDown(t) 918 | 919 | john, jane := createUserAccounts() 920 | 921 | claSigners := config.ClaSigners{ 922 | People: []config.Account{ 923 | john, 924 | }, 925 | Bots: []config.Account{ 926 | jane, 927 | }, 928 | } 929 | 930 | commits := []*github.RepositoryCommit{ 931 | createCommit(john, john), 932 | createCommit(john, jane), 933 | createCommit(jane, john), 934 | createCommit(jane, jane), 935 | } 936 | 937 | for _, commit := range commits { 938 | assert.False(t, ghutil.IsExternal(commit, claSigners, false), 939 | "commit should not be considered external: %v", *commit) 940 | } 941 | } 942 | 943 | func TestIsExternal_JaneIsExternalPerson(t *testing.T) { 944 | setUp(t) 945 | defer tearDown(t) 946 | 947 | john, jane := createUserAccounts() 948 | 949 | claSigners := config.ClaSigners{ 950 | People: []config.Account{ 951 | john, 952 | }, 953 | External: &config.ExternalClaSigners{ 954 | People: []config.Account{ 955 | jane, 956 | }, 957 | }, 958 | } 959 | 960 | commits := []*github.RepositoryCommit{ 961 | createCommit(john, jane), 962 | createCommit(jane, jane), 963 | createCommit(jane, john), 964 | } 965 | 966 | for _, commit := range commits { 967 | assert.True(t, ghutil.IsExternal(commit, claSigners, false), 968 | "commit should be considered external: %v", *commit) 969 | } 970 | } 971 | 972 | func TestIsExternal_JaneIsExternalBot(t *testing.T) { 973 | setUp(t) 974 | defer tearDown(t) 975 | 976 | john, jane := createUserAccounts() 977 | 978 | claSigners := config.ClaSigners{ 979 | People: []config.Account{ 980 | john, 981 | }, 982 | External: &config.ExternalClaSigners{ 983 | Bots: []config.Account{ 984 | jane, 985 | }, 986 | }, 987 | } 988 | 989 | commits := []*github.RepositoryCommit{ 990 | createCommit(john, jane), 991 | createCommit(jane, jane), 992 | createCommit(jane, john), 993 | } 994 | 995 | for _, commit := range commits { 996 | assert.True(t, ghutil.IsExternal(commit, claSigners, false), 997 | "commit should be considered external: %v", *commit) 998 | } 999 | } 1000 | 1001 | func TestIsExternal_JaneIsExternalCorporate(t *testing.T) { 1002 | setUp(t) 1003 | defer tearDown(t) 1004 | 1005 | john, jane := createUserAccounts() 1006 | 1007 | claSigners := config.ClaSigners{ 1008 | People: []config.Account{ 1009 | john, 1010 | }, 1011 | External: &config.ExternalClaSigners{ 1012 | Companies: []config.Company{ 1013 | { 1014 | Name: "company", 1015 | People: []config.Account{ 1016 | jane, 1017 | }, 1018 | }, 1019 | }, 1020 | }, 1021 | } 1022 | 1023 | commits := []*github.RepositoryCommit{ 1024 | createCommit(john, jane), 1025 | createCommit(jane, jane), 1026 | createCommit(jane, john), 1027 | } 1028 | 1029 | for _, commit := range commits { 1030 | assert.True(t, ghutil.IsExternal(commit, claSigners, false), 1031 | "commit should be considered external: %v", *commit) 1032 | } 1033 | } 1034 | 1035 | func TestIsExternal_JaneIsCorporate_UnknownAsExternal(t *testing.T) { 1036 | setUp(t) 1037 | defer tearDown(t) 1038 | 1039 | john, jane := createUserAccounts() 1040 | 1041 | claSigners := config.ClaSigners{ 1042 | People: []config.Account{ 1043 | john, 1044 | }, 1045 | Companies: []config.Company{ 1046 | { 1047 | Name: "company", 1048 | People: []config.Account{ 1049 | jane, 1050 | }, 1051 | }, 1052 | }, 1053 | } 1054 | 1055 | commits := []*github.RepositoryCommit{ 1056 | createCommit(john, jane), 1057 | createCommit(jane, jane), 1058 | createCommit(jane, john), 1059 | } 1060 | 1061 | for _, commit := range commits { 1062 | assert.False(t, ghutil.IsExternal(commit, claSigners, true), 1063 | "commit should not be considered external: %v", *commit) 1064 | } 1065 | } 1066 | 1067 | func TestIsExternal_JaneIsUnlisted_UnknownAsExternal(t *testing.T) { 1068 | setUp(t) 1069 | defer tearDown(t) 1070 | 1071 | john, jane := createUserAccounts() 1072 | 1073 | claSigners := config.ClaSigners{ 1074 | People: []config.Account{ 1075 | john, 1076 | }, 1077 | } 1078 | 1079 | commits := []*github.RepositoryCommit{ 1080 | createCommit(john, jane), 1081 | createCommit(jane, jane), 1082 | createCommit(jane, john), 1083 | } 1084 | 1085 | for _, commit := range commits { 1086 | assert.True(t, ghutil.IsExternal(commit, claSigners, true), 1087 | "commit should be considered external: %v", *commit) 1088 | } 1089 | } 1090 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/code-review-bot 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/go-yaml/yaml v2.1.0+incompatible 7 | github.com/golang/mock v1.6.0 8 | github.com/google/go-github/v21 v21.0.0 9 | github.com/stretchr/testify v1.4.0 10 | golang.org/x/oauth2 v0.0.0-20190115181402-5dab4167f31c 11 | ) 12 | 13 | require ( 14 | github.com/kr/pretty v0.1.0 // indirect 15 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= 5 | github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 6 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 7 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 8 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 9 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 10 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 11 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 12 | github.com/google/go-github/v21 v21.0.0 h1:tn4/tmCgPAsezJFwZcMnE7U0R9/AtKRBGX4s4LFdDzI= 13 | github.com/google/go-github/v21 v21.0.0/go.mod h1:RNbKQQDOg+lBuuu5l/v0joCrygzKEexxDEwaleXEHxA= 14 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 15 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 16 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 17 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 18 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 19 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 20 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 25 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 26 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 27 | golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 28 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 29 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 30 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 31 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 32 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 33 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 34 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 35 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 36 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= 37 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 38 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 39 | golang.org/x/oauth2 v0.0.0-20190115181402-5dab4167f31c h1:pcBdqVcrlT+A3i+tWsOROFONQyey9tisIQHI4xqVGLg= 40 | golang.org/x/oauth2 v0.0.0-20190115181402-5dab4167f31c/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 41 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 44 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 45 | golang.org/x/sys v0.0.0-20180824143301-4910a1d54f87/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 46 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 48 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 54 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 55 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 56 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 57 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 59 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 60 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 61 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 62 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 65 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 66 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 67 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 68 | -------------------------------------------------------------------------------- /go_mod_tidy_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -u 2 | # 3 | # Copyright 2019 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Verifies that we have run `go mod tidy` to keep our modules config clean. 18 | 19 | # Go 1.16 seems to differ in how it formats `go mod` using `go mod tidy`, which 20 | # makes it incompatible with all prior versions; see the output logs in 21 | # https://github.com/google/code-review-bot/actions/runs/695615181 for the 22 | # differences in results. Thus, for now, we'll skip this test for Go 1.16 so that: 23 | # 24 | # (a) we can continue to test all other functionality with Go 1.16 25 | # (b) users using Go 1.16 can run `make test` just like everyone else 26 | if [ -n "$(go version | grep 'go1.16')" ]; then 27 | echo "WARNING: Go 1.16 uses a different format for 'go mod tidy' output." >&2 28 | echo "WARNING: See https://github.com/google/code-review-bot/actions/runs/695615181 for details." >&2 29 | echo "WARNING: Skipping $(basename $0) for now." >&2 30 | exit 31 | fi 32 | 33 | declare -r GO_MOD="go.mod" 34 | declare -r GO_SUM="go.sum" 35 | 36 | declare -r GO_MOD_ORIG="go.mod.orig" 37 | declare -r GO_SUM_ORIG="go.sum.orig" 38 | 39 | declare -i success=0 40 | 41 | cp "${GO_MOD}" "${GO_MOD_ORIG}" 42 | cp "${GO_SUM}" "${GO_SUM_ORIG}" 43 | 44 | go mod tidy 45 | 46 | diff -u "${GO_MOD}" "${GO_MOD_ORIG}" || success=1 47 | diff -u "${GO_SUM}" "${GO_SUM_ORIG}" || success=1 48 | 49 | mv "${GO_MOD_ORIG}" "${GO_MOD}" 50 | mv "${GO_SUM_ORIG}" "${GO_SUM}" 51 | 52 | if [[ ${success} == 0 ]]; then 53 | echo PASSED 54 | else 55 | echo FAILED 56 | fi 57 | exit ${success} 58 | -------------------------------------------------------------------------------- /gofmt_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -u 2 | # 3 | # Copyright 2017 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Verifies that all *.go files are formatted according to `gofmt`. 18 | 19 | declare -r VERBOSE="${VERBOSE:-}" 20 | 21 | declare -i global_status=0 22 | declare -i local_status=0 23 | 24 | declare -i num_files_passed=0 25 | declare -i num_files_failed=0 26 | 27 | for gosrc in `find . -name \*\.go`; do 28 | if [[ "${VERBOSE}" -eq 1 ]]; then 29 | diff -u "${gosrc}" <(gofmt -s "${gosrc}") 30 | else 31 | diff -u "${gosrc}" <(gofmt -s "${gosrc}") > /dev/null 2>&1 32 | fi 33 | local_status=$? 34 | if [[ ${local_status} != 0 ]]; then 35 | echo "failed: ${gosrc}" 36 | global_status=${local_status} 37 | num_files_failed=$((num_files_failed + 1)) 38 | else 39 | num_files_passed=$((num_files_passed + 1)) 40 | fi 41 | done 42 | 43 | echo "gofmt files passed: ${num_files_passed} / $((num_files_passed + num_files_failed))" 44 | exit ${global_status} 45 | -------------------------------------------------------------------------------- /logging/logging.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 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 logging 16 | 17 | import ( 18 | "fmt" 19 | "log" 20 | "os" 21 | ) 22 | 23 | // Errorf outputs an error log line with a formatting string. 24 | func Errorf(format string, a ...interface{}) (int, error) { 25 | return fmt.Fprintf(os.Stderr, format+"\n", a...) 26 | } 27 | 28 | // Error outputs an error log line without a formatting string. 29 | func Error(a ...interface{}) (int, error) { 30 | return fmt.Fprintln(os.Stderr, a...) 31 | } 32 | 33 | // Infof outputs an info log line with a formatting string. 34 | func Infof(format string, a ...interface{}) (int, error) { 35 | return fmt.Printf(format+"\n", a...) 36 | } 37 | 38 | // Info outputs an info log line without a formatting string. 39 | func Info(a ...interface{}) (int, error) { 40 | return fmt.Println(a...) 41 | } 42 | 43 | // Fatalf outputs a fatal log line with a formatting string. 44 | func Fatalf(format string, a ...interface{}) { 45 | log.Fatalf(format+"\n", a...) 46 | } 47 | 48 | // Fatal outputs a fatal log line without a formatting string. 49 | func Fatal(a ...interface{}) { 50 | log.Fatal(a...) 51 | } 52 | --------------------------------------------------------------------------------