├── .github ├── dependabot.yml ├── pull_request_template.md ├── release_template.yaml └── workflows │ ├── golangci-lint.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── commenter │ └── main.go ├── go.mod ├── go.sum ├── golangci.yaml ├── pkg ├── app │ ├── app.go │ └── commenter.go └── commenter │ ├── azure │ └── azure.go │ ├── bitbucket-server │ └── bitbucket-server.go │ ├── bitbucket │ └── bitbucket.go │ ├── commenter.go │ ├── github │ ├── commitFileInfo.go │ ├── connector.go │ ├── errors.go │ └── github.go │ ├── gitlab │ └── gitlab.go │ ├── jenkins │ └── jenkins.go │ ├── mock │ └── mock.go │ └── utils │ ├── bitbucket │ └── bitbucket-utils.go │ ├── change-report │ └── change-report.go │ └── comment_utils.go ├── release.yaml └── scripts └── release.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: gomod 8 | open-pull-requests-limit: 10 9 | directory: / 10 | schedule: 11 | interval: monthly 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | [JIRA-XXXX](https://scalock.atlassian.net/browse/SAAS-XXXX) 4 | 5 | _Summary of the change in this repository_ 6 | 7 | ## Type of change 8 | 9 | - [ ] Chore (README.md update / pipeline update / etc) 10 | - [ ] Bug fix **(non-breaking change which fixes an issue)** 11 | - [ ] Vulnerability fix **(non-breaking change without code changes)** 12 | - [ ] New feature **(non-breaking change which adds functionality)** 13 | - [ ] Breaking change **(_(major version increased)_ - no backward compatibility)** 14 | 15 | ## Testing 16 | 17 | - [ ] Added / Ran automated tests **(unit / integration / e2e)** 18 | - [ ] Manual tests - **local environment** 19 | - [ ] Manual tests - **dev/staging environment** 20 | 21 | ## Release 22 | 23 | - [ ] Documentation update required? **_(Notified PM on required changes)_** 24 | - [ ] **Requires UI / CICD client / scanner alignment?** 25 | - [ ] **Requires infrastructure alignment?** 26 | - [ ] **Requires migration?** 27 | - [ ] **Has Datadog monitoring?** 28 | - [ ] **No new HIGH / CRITICAL vulnerabilities? _(FIPS ready / Updated KNOW_VULNERABILITIES.md)_** 29 | 30 | ## Links to related PRs / Migration scripts / Documentation 31 | 32 | ## Screenshots / Video -------------------------------------------------------------------------------- /.github/release_template.yaml: -------------------------------------------------------------------------------- 1 | name: "commenter" 2 | repository: github.com/aquasecurity/go-git-pr-commenter 3 | version: "PLACEHOLDERVERSION" 4 | usage: commenter cmd 5 | description: commenter adding comments to git PRs 6 | platforms: 7 | - selector: # optional 8 | os: linux 9 | arch: amd64 10 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/PLACEHOLDERVERSION/linux_amd64_PLACEHOLDERVERSION.tar.gz 11 | bin: ./commenter 12 | - selector: 13 | os: linux 14 | arch: arm64 15 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/PLACEHOLDERVERSION/linux_arm64_PLACEHOLDERVERSION.tar.gz 16 | bin: ./commenter 17 | - selector: 18 | os: darwin 19 | arch: amd64 20 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/PLACEHOLDERVERSION/darwin_amd64_PLACEHOLDERVERSION.tar.gz 21 | bin: ./commenter 22 | - selector: 23 | os: darwin 24 | arch: arm64 25 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/PLACEHOLDERVERSION/darwin_arm64_PLACEHOLDERVERSION.tar.gz 26 | bin: ./commenter 27 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | paths-ignore: 10 | - '*.md' 11 | permissions: 12 | contents: read 13 | pull-requests: read 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Configure git url 21 | run: | 22 | git config --global url.https://${{ secrets.ARGON_GH_TOKEN }}@github.com/.insteadOf https://github.com/ 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v3 25 | env: 26 | GOPRIVATE: github.com/argonsecurity/* 27 | with: 28 | args: --timeout=5m 29 | 30 | - name: Remove git url 31 | run: | 32 | git config --global url.https://github.com/.insteadOf https://${{ secrets.ARGON_GH_TOKEN }}@github.com/ 33 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: testing for PR 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | name: testing PR build 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-go@v3 17 | with: 18 | stable: "false" 19 | go-version: "1.18" 20 | - run: go version 21 | 22 | - name: Configure git url 23 | run: | 24 | git config --global url.https://${{ secrets.ARGON_GH_TOKEN }}@github.com/.insteadOf https://github.com/ 25 | - name: Run tests 26 | env: 27 | GOPRIVATE: github.com/argonsecurity/* 28 | run: make test 29 | - name: Remove git url 30 | run: | 31 | git config --global url.https://github.com/.insteadOf https://${{ secrets.ARGON_GH_TOKEN }}@github.com/ 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: go-git-pr-commenter release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: releasing go-git-pr-commenter 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-go@v3 19 | with: 20 | stable: 'false' 21 | go-version: '1.18' 22 | - run: go version 23 | 24 | - name: Release 25 | uses: goreleaser/goreleaser-action@v3 26 | with: 27 | version: latest 28 | args: release --rm-dist 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | .idea 3 | /vendor -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | - go mod download 5 | builds: 6 | - main: ./cmd/commenter 7 | binary: commenter 8 | env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | 17 | changelog: 18 | sort: asc 19 | filters: 20 | exclude: 21 | - '^docs:' 22 | - '^test:' 23 | 24 | archives: 25 | - name_template: "{{ .Os }}_{{ .Arch }}_v{{ .Version }}" 26 | 27 | release: 28 | prerelease: auto 29 | github: 30 | owner: aquasecurity 31 | name: go-git-pr-commenter 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/usr/bin/env bash 2 | 3 | .PHONY: release 4 | release: 5 | @./scripts/release.sh 6 | .PHONY: test 7 | test: 8 | go test -v ./... 9 | 10 | .PHONY: build 11 | build: 12 | docker run \ 13 | --rm \ 14 | -e GOARCH=amd64 \ 15 | -e GOOS=linux \ 16 | -w /build \ 17 | -v `pwd`:/build \ 18 | golang:1.18 \ 19 | go build -o /build/bin/commenter cmd/commenter/main.go || exit 1 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-git-pr-commenter 2 | 3 | command line tool and package based for git comments 4 | 5 | # cmd example 6 | 7 | GitHub: 8 | 9 | export GITHUB_TOKEN=xxxx 10 | 11 | ./commenter cmd -f file.yaml -c comment -v github --start-line 17 --end-line 20 --pr-number 9 --repo testing --owner repo_owner 12 | 13 | Gitlab: 14 | 15 | export GITLAB_TOKEN=xxxx 16 | export CI_PROJECT_ID=xxxx 17 | export CI_MERGE_REQUEST_IID=xxxx 18 | export CI_API_V4_URL=xxxx 19 | 20 | ./commenter cmd -f file.yaml -c comment -v gitlab --start-line 18 21 | 22 | Azure: 23 | 24 | export AZURE_TOKEN=xxxx 25 | export SYSTEM_TEAMPROJECT=xxxx 26 | export BUILD_REPOSITORY_ID=xxxx 27 | export SYSTEM_PULLREQUEST_PULLREQUESTID=xxxx 28 | export SYSTEM_COLLECTIONURI=xxxx 29 | 30 | ./commenter cmd -f /file.yaml -c best_comment -v azure --start-line 1 --end-line 1 --owner repo_organization 31 | 32 | BitBucket: 33 | 34 | export BITBUCKET_TOKEN=xxxx 35 | export BITBUCKET_USER=xxxx 36 | export BITBUCKET_API_URL=xxxx 37 | export BITBUCKET_PR_ID=xxxx 38 | export BITBUCKET_REPO_FULL_NAME=xxxx 39 | 40 | ./commenter cmd -f file.yaml -c best_comment -v bitbucket --start-line 1 --end-line 1 41 | 42 | # Credits 43 | 44 | Initially inspired and based on https://github.com/owenrumney/go-github-pr-commenter/ 45 | -------------------------------------------------------------------------------- /cmd/commenter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aquasecurity/go-git-pr-commenter/pkg/app" 5 | "log" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | app := app.NewApp() 11 | err := app.Run(os.Args) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aquasecurity/go-git-pr-commenter 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/argonsecurity/go-environments v0.1.44 7 | github.com/google/go-github/v44 v44.1.0 8 | github.com/rs/zerolog v1.33.0 9 | github.com/samber/lo v1.37.0 10 | github.com/urfave/cli/v2 v2.8.1 11 | golang.org/x/oauth2 v0.1.0 12 | ) 13 | 14 | require ( 15 | github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect 16 | github.com/golang/protobuf v1.5.2 // indirect 17 | github.com/google/go-querystring v1.1.0 // indirect 18 | github.com/mattn/go-colorable v0.1.13 // indirect 19 | github.com/mattn/go-isatty v0.0.19 // indirect 20 | github.com/pkg/errors v0.9.1 // indirect 21 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 22 | github.com/thoas/go-funk v0.9.2 // indirect 23 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 24 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 25 | golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d // indirect 26 | golang.org/x/net v0.1.0 // indirect 27 | golang.org/x/sys v0.12.0 // indirect 28 | google.golang.org/appengine v1.6.7 // indirect 29 | google.golang.org/protobuf v1.28.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/argonsecurity/go-environments v0.1.44 h1:iyQfMp6QS9qwjYsWr4N+ZMiwA5eKfSa/QHsM6pgG+2I= 2 | github.com/argonsecurity/go-environments v0.1.44/go.mod h1:gf8wUiuP2DU6NN5nvfBy3h48R2JaPB7HAMag/DC0JUo= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 10 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 11 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 12 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 13 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 14 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 16 | github.com/google/go-github/v44 v44.1.0 h1:shWPaufgdhr+Ad4eo/pZv9ORTxFpsxPEPEuuXAKIQGA= 17 | github.com/google/go-github/v44 v44.1.0/go.mod h1:iWn00mWcP6PRWHhXm0zuFJ8wbEjE5AGO5D5HXYM4zgw= 18 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 19 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 20 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 21 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 22 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 23 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 24 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 25 | github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= 26 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 27 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 31 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 32 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 33 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 34 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 35 | github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw= 36 | github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 39 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 40 | github.com/thoas/go-funk v0.9.2 h1:oKlNYv0AY5nyf9g+/GhMgS/UO2ces0QRdPKwkhY3VCk= 41 | github.com/thoas/go-funk v0.9.2/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= 42 | github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4= 43 | github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY= 44 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 45 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= 48 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 49 | golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0= 50 | golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= 51 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 52 | golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= 53 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 54 | golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y= 55 | golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= 56 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 57 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 60 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 62 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 63 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 64 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 65 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 66 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 67 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 68 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 69 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 70 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | -------------------------------------------------------------------------------- /golangci.yaml: -------------------------------------------------------------------------------- 1 | 2 | linters: 3 | disable-all: true 4 | enable: 5 | - deadcode 6 | - errcheck 7 | - gosimple 8 | - govet 9 | - ineffassign 10 | - staticcheck 11 | - structcheck 12 | - unused 13 | - varcheck 14 | - bodyclose 15 | - contextcheck 16 | - cyclop 17 | - durationcheck 18 | - errname 19 | - errorlint 20 | - exportloopref 21 | - goimports 22 | - gosec 23 | - gocritic 24 | linters-settings: 25 | funlen: 26 | lines: 150 27 | statements: 80 28 | issues: 29 | exclude-rules: 30 | - path: "." 31 | linters: 32 | - typecheck 33 | -------------------------------------------------------------------------------- /pkg/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | func NewApp() *cli.App { 8 | app := cli.NewApp() 9 | app.EnableBashCompletion = true 10 | app.Commands = []*cli.Command{ 11 | { 12 | Name: "cmd", 13 | Action: Action, 14 | Flags: []cli.Flag{ 15 | &cli.StringFlag{ 16 | Name: "file", 17 | Aliases: []string{"f"}, 18 | Usage: "Target file", 19 | }, 20 | &cli.StringFlag{ 21 | Name: "comment", 22 | Aliases: []string{"c"}, 23 | Usage: "PR comment", 24 | }, 25 | &cli.StringFlag{ 26 | Name: "vendor", 27 | Aliases: []string{"v"}, 28 | Usage: "The vendor for the comment mock|github|bitbucket", 29 | }, 30 | &cli.IntFlag{ 31 | Name: "start-line", 32 | Aliases: []string{"s"}, 33 | Usage: "Comment start line", 34 | }, 35 | &cli.IntFlag{ 36 | Name: "end-line", 37 | Aliases: []string{"e"}, 38 | Usage: "Comment end line", 39 | }, 40 | &cli.StringFlag{ 41 | Name: "repo", 42 | Usage: "The repo name", 43 | }, 44 | &cli.IntFlag{ 45 | Name: "pr-number", 46 | Usage: "The pr number", 47 | }, 48 | &cli.StringFlag{ 49 | Name: "owner", 50 | Usage: "The repo owner", 51 | }, 52 | &cli.StringFlag{ 53 | Name: "project", 54 | Usage: "The project name (azure)", 55 | }, 56 | &cli.StringFlag{ 57 | Name: "collection-url", 58 | Usage: "The collection url (azure)", 59 | }, 60 | &cli.StringFlag{ 61 | Name: "repo-id", 62 | Usage: "The repository ID (azure)", 63 | }, 64 | }, 65 | }, 66 | } 67 | return app 68 | } 69 | -------------------------------------------------------------------------------- /pkg/app/commenter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 8 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/azure" 9 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/bitbucket" 10 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/github" 11 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/gitlab" 12 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/mock" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func Action(ctx *cli.Context) (err error) { 17 | var c = commenter.Repository(nil) 18 | switch ctx.String("vendor") { 19 | case "mock": 20 | c = commenter.Repository(mock.NewMock()) 21 | case "github": 22 | token := os.Getenv("GITHUB_TOKEN") 23 | r, err := github.NewGithub(token, ctx.String("owner"), ctx.String("repo"), ctx.Int("pr-number")) 24 | if err != nil { 25 | return err 26 | } 27 | c = commenter.Repository(r) 28 | case "gitlab": 29 | token := os.Getenv("GITLAB_TOKEN") 30 | r, err := gitlab.NewGitlab( 31 | token, "", "", "") 32 | if err != nil { 33 | return err 34 | } 35 | c = commenter.Repository(r) 36 | case "azure": 37 | token := os.Getenv("AZURE_TOKEN") 38 | r, err := azure.NewAzure(token, ctx.String("project"), ctx.String("collection-url"), ctx.String("repo-id"), ctx.String("pr-number")) 39 | if err != nil { 40 | return err 41 | } 42 | c = commenter.Repository(r) 43 | case "bitbucket": 44 | userName := os.Getenv("BITBUCKET_USER") 45 | token := os.Getenv("BITBUCKET_TOKEN") 46 | r, err := bitbucket.NewBitbucket(userName, token) 47 | if err != nil { 48 | return err 49 | } 50 | c = commenter.Repository(r) 51 | } 52 | 53 | err = c.WriteMultiLineComment( 54 | ctx.String("file"), 55 | ctx.String("comment"), 56 | ctx.Int("start-line"), 57 | ctx.Int("end-line")) 58 | if err != nil { 59 | return fmt.Errorf("failed write comment: %w", err) 60 | } 61 | 62 | return nil 63 | 64 | } 65 | -------------------------------------------------------------------------------- /pkg/commenter/azure/azure.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils" 14 | 15 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 16 | ) 17 | 18 | type Azure struct { 19 | Token string 20 | RepoID string 21 | PrNumber string 22 | Project string 23 | ApiUrl string 24 | } 25 | 26 | type ThreadsResponse struct { 27 | Threads []Thread `json:"value,omitempty"` 28 | } 29 | 30 | type Thread struct { 31 | Id int `json:"id,omitempty"` 32 | Comments []Comment `json:"comments,omitempty"` 33 | } 34 | 35 | type LineStruct struct { 36 | Line int `json:"line,omitempty"` 37 | Offset int `json:"offset,omitempty"` 38 | } 39 | 40 | type ThreadContext struct { 41 | FilePath string `json:"filePath,omitempty"` 42 | RightFileEnd LineStruct `json:"rightFileEnd,omitempty"` 43 | RightFileStart LineStruct `json:"rightFileStart,omitempty"` 44 | } 45 | 46 | type Body struct { 47 | Comments []Comment `json:"comments,omitempty"` 48 | Status int `json:"status,omitempty"` 49 | ThreadContext ThreadContext `json:"threadContext,omitempty"` 50 | } 51 | 52 | type Comment struct { 53 | Id int `json:"id,omitempty"` 54 | ParentCommentId int `json:"parentCommentId,omitempty"` 55 | Content string `json:"content,omitempty"` 56 | } 57 | 58 | func NewAzure(token, project, collectionUrl, repoId, prNumber string) (b *Azure, err error) { 59 | projectNameParm := project 60 | if projectNameParm == "" { 61 | projectNameParm = os.Getenv("SYSTEM_TEAMPROJECT") 62 | } 63 | apiURLParam := collectionUrl 64 | if apiURLParam == "" { 65 | apiURLParam = os.Getenv("SYSTEM_COLLECTIONURI") 66 | } 67 | repoIDParam := repoId 68 | if repoIDParam == "" { 69 | repoIDParam = os.Getenv("BUILD_REPOSITORY_ID") 70 | } 71 | prNumberParam := prNumber 72 | if prNumberParam == "" { 73 | prNumberParam = os.Getenv("SYSTEM_PULLREQUEST_PULLREQUESTID") 74 | } 75 | 76 | return &Azure{ 77 | Project: projectNameParm, 78 | ApiUrl: apiURLParam, 79 | Token: token, 80 | RepoID: repoIDParam, 81 | PrNumber: prNumberParam, 82 | }, nil 83 | } 84 | 85 | // WriteMultiLineComment writes a multiline review on a file in the azure PR 86 | func (c *Azure) WriteMultiLineComment(file, comment string, startLine, endLine int) error { 87 | if !strings.HasPrefix(file, "/") { 88 | file = fmt.Sprintf("/%s", file) 89 | } 90 | 91 | if startLine == commenter.FIRST_AVAILABLE_LINE || startLine == 0 { 92 | // Reference: https://developercommunity.visualstudio.com/t/Adding-thread-to-PR-using-REST-API-cause/10598424 93 | startLine = 1 94 | } 95 | 96 | if endLine == commenter.FIRST_AVAILABLE_LINE || endLine == 0 { 97 | // Reference: https://developercommunity.visualstudio.com/t/Adding-thread-to-PR-using-REST-API-cause/10598424 98 | endLine = 1 99 | } 100 | 101 | b := Body{ 102 | Comments: []Comment{ 103 | { 104 | ParentCommentId: 1, 105 | Content: comment, 106 | }, 107 | }, 108 | 109 | Status: 1, 110 | ThreadContext: ThreadContext{ 111 | FilePath: file, 112 | RightFileEnd: LineStruct{ 113 | Line: endLine, 114 | Offset: 999, 115 | }, 116 | RightFileStart: LineStruct{ 117 | Line: startLine, 118 | Offset: 1, 119 | }, 120 | }, 121 | } 122 | 123 | reqBody, err := json.Marshal(b) 124 | if err != nil { 125 | return fmt.Errorf("failed to marshal body for azure api: %s", err) 126 | } 127 | 128 | client := &http.Client{} 129 | req, err := http.NewRequest("POST", fmt.Sprintf("%s%s/_apis/git/repositories/%s/pullRequests/%s/threads?api-version=6.0", 130 | c.ApiUrl, c.Project, c.RepoID, c.PrNumber), 131 | strings.NewReader(string(reqBody))) 132 | if err != nil { 133 | return err 134 | } 135 | req.Header.Add("Content-Type", "application/json") 136 | req.SetBasicAuth("", c.Token) 137 | 138 | resp, err := client.Do(req) 139 | if err != nil { 140 | return err 141 | } 142 | if resp.StatusCode != http.StatusOK { 143 | b, _ := io.ReadAll(resp.Body) 144 | return fmt.Errorf("failed write azure line comment: %s", string(b)) 145 | } 146 | 147 | return nil 148 | 149 | } 150 | 151 | // WriteLineComment writes a single review line on a file of the azure PR 152 | func (c *Azure) WriteLineComment(_, _ string, _ int) error { 153 | 154 | return nil 155 | } 156 | 157 | func (c *Azure) RemovePreviousAquaComments(msg string) error { 158 | 159 | resp, err := utils.GetComments(fmt.Sprintf("%s%s/_apis/git/repositories/%s/pullRequests/%s/threads?api-version=6.0", 160 | c.ApiUrl, c.Project, c.RepoID, c.PrNumber), map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+c.Token))}) 161 | if err != nil { 162 | return fmt.Errorf("failed getting comments with error: %w", err) 163 | } 164 | body, err := io.ReadAll(resp.Body) 165 | if err != nil { 166 | return err 167 | } 168 | defer resp.Body.Close() 169 | 170 | commentsResponse := ThreadsResponse{} 171 | err = json.Unmarshal(body, &commentsResponse) 172 | if err != nil { 173 | return fmt.Errorf("failed unmarshal response body with error: %w", err) 174 | } 175 | 176 | for _, thread := range commentsResponse.Threads { 177 | for _, comment := range thread.Comments { 178 | if strings.Contains(comment.Content, msg) { 179 | err = utils.DeleteComments(fmt.Sprintf("%s%s/_apis/git/repositories/%s/pullRequests/%s/threads/%s/comments/%s?api-version=6.0", 180 | c.ApiUrl, c.Project, c.RepoID, c.PrNumber, strconv.Itoa(thread.Id), strconv.Itoa(comment.Id)), map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+c.Token))}) 181 | if err != nil { 182 | return fmt.Errorf("failed deleting comment with error: %w", err) 183 | } 184 | } 185 | } 186 | } 187 | return nil 188 | } 189 | -------------------------------------------------------------------------------- /pkg/commenter/bitbucket-server/bitbucket-server.go: -------------------------------------------------------------------------------- 1 | package bitbucket_server 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils" 13 | change_report "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils/change-report" 14 | 15 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 16 | ) 17 | 18 | const LIMIT = 500 19 | 20 | type BitbucketServer struct { 21 | Token string 22 | UserName string 23 | Project string 24 | Repo string 25 | PrNumber string 26 | ApiUrl string 27 | ChangeReport change_report.ChangeReport 28 | } 29 | 30 | type ActivitiesResponse struct { 31 | Activities []Activity `json:"values,omitempty"` 32 | IsLastPage bool `json:"isLastPage"` 33 | Start int `json:"start"` 34 | NextPageStart int `json:"nextPageStart,omitempty"` 35 | } 36 | 37 | type Activity struct { 38 | Id int `json:"id,omitempty"` 39 | Action string `json:"action,omitempty"` 40 | CommentAction string `json:"commentAction,omitempty"` 41 | Comment Comment `json:"comment,omitempty"` 42 | } 43 | 44 | type Comment struct { 45 | Id int `json:"id,omitempty"` 46 | Version int `json:"version,omitempty"` 47 | Text string `json:"text,omitempty"` 48 | } 49 | 50 | type NewComment struct { 51 | Test string `json:"text"` 52 | Anchor Anchor `json:"anchor"` 53 | } 54 | 55 | type Anchor struct { 56 | Line int `json:"line"` 57 | LineType string `json:"lineType"` 58 | FileType string `json:"fileType"` 59 | Path string `json:"path"` 60 | } 61 | 62 | func NewBitbucketServer(apiUrl, userName, token, prNumber, project, repo, baseRef string) (b *BitbucketServer, err error) { 63 | changeReport, err := change_report.GenerateChangeReport(baseRef) 64 | fmt.Println("Creating Bitbucket Server client parameters:") 65 | fmt.Println("apiUrl: ", apiUrl) 66 | fmt.Println("userName: ", userName) 67 | fmt.Println("prNumber: ", prNumber) 68 | fmt.Println("project: ", project) 69 | fmt.Println("repo: ", repo) 70 | 71 | return &BitbucketServer{ 72 | ApiUrl: apiUrl, 73 | UserName: userName, 74 | Token: token, 75 | Project: project, 76 | Repo: repo, 77 | PrNumber: prNumber, 78 | ChangeReport: changeReport, 79 | }, err 80 | } 81 | 82 | func (c *BitbucketServer) WriteMultiLineComment(file, comment string, startLine, _ int) error { 83 | // In bitbucket we support one line only 84 | err := c.WriteLineComment(file, comment, startLine) 85 | if err != nil { 86 | return fmt.Errorf("failed to write bitbucket server multiline comment: %w", err) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func (c *BitbucketServer) WriteLineComment(file, comment string, line int) error { 93 | if line == commenter.FIRST_AVAILABLE_LINE || line == 0 { 94 | line = 1 95 | } 96 | 97 | changeType := change_report.CONTEXT 98 | if filechange, ok := c.ChangeReport[file]; ok { 99 | if _, ok := filechange.AddedLines[line]; ok { 100 | changeType = change_report.ADDED 101 | } 102 | } 103 | 104 | b := NewComment{ 105 | Test: comment, 106 | Anchor: Anchor{ 107 | Line: line, 108 | LineType: string(changeType), 109 | FileType: "TO", 110 | Path: file, 111 | }, 112 | } 113 | 114 | reqBody, err := json.Marshal(b) 115 | if err != nil { 116 | return fmt.Errorf("failed to marshal body for bitbucket server api: %s", err) 117 | } 118 | 119 | client := &http.Client{} 120 | req, err := http.NewRequest("POST", c.getCommentPostUrl(), strings.NewReader(string(reqBody))) 121 | if err != nil { 122 | return err 123 | } 124 | req.Header.Add("Content-Type", "application/json") 125 | req.SetBasicAuth(c.UserName, c.Token) 126 | 127 | resp, err := client.Do(req) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | if resp.StatusCode != http.StatusCreated { 133 | b, _ := io.ReadAll(resp.Body) 134 | return fmt.Errorf("failed write bitbucket line comment: %s", string(b)) 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func (c *BitbucketServer) getIdsToRemove(commentsToRemove []Comment, msg string, start int) ([]Comment, error) { 141 | url, err := utils.UrlWithParams(c.getCommentsUrl(), getCommentsParams(start)) 142 | if err != nil { 143 | return nil, fmt.Errorf("failed to create comments url: %w", err) 144 | } 145 | 146 | resp, err := utils.GetComments(url, c.getAuthHeaders()) 147 | if err != nil { 148 | return nil, fmt.Errorf("failed getting comments with error: %w", err) 149 | } 150 | 151 | body, err := io.ReadAll(resp.Body) 152 | if err != nil { 153 | return nil, err 154 | } 155 | defer resp.Body.Close() 156 | 157 | activitiesResponse := ActivitiesResponse{} 158 | err = json.Unmarshal(body, &activitiesResponse) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed unmarshal response body with error: %w", err) 161 | } 162 | 163 | for _, value := range activitiesResponse.Activities { 164 | if value.CommentAction == "ADDED" && value.Action == "COMMENTED" && strings.Contains(value.Comment.Text, msg) { 165 | commentsToRemove = append(commentsToRemove, value.Comment) 166 | } 167 | } 168 | 169 | if activitiesResponse.IsLastPage { 170 | return commentsToRemove, nil 171 | } 172 | return c.getIdsToRemove(commentsToRemove, msg, activitiesResponse.NextPageStart) 173 | 174 | } 175 | 176 | func (c *BitbucketServer) RemovePreviousAquaComments(msg string) error { 177 | var commentsToRemove []Comment 178 | commentsToRemove, err := c.getIdsToRemove(commentsToRemove, msg, 0) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | for _, comment := range commentsToRemove { 184 | url, _ := utils.UrlWithParams(c.getCommentDeleteUrl(comment.Id), map[string]string{"version": strconv.Itoa(comment.Version)}) 185 | err := utils.DeleteComments(url, c.getAuthHeaders()) 186 | if err != nil { 187 | fmt.Printf("failed to delete comment with id %d: %s", comment.Id, err) 188 | } 189 | } 190 | 191 | return nil 192 | } 193 | 194 | func (c *BitbucketServer) getCommentsUrl() string { 195 | return fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%s/activities", c.ApiUrl, c.Project, c.Repo, c.PrNumber) 196 | } 197 | 198 | func (c *BitbucketServer) getCommentDeleteUrl(id int) string { 199 | return fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%s/comments/%d", c.ApiUrl, c.Project, c.Repo, c.PrNumber, id) 200 | } 201 | 202 | func (c *BitbucketServer) getCommentPostUrl() string { 203 | return fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%s/comments", c.ApiUrl, c.Project, c.Repo, c.PrNumber) 204 | } 205 | 206 | func (c *BitbucketServer) getAuthHeaders() map[string]string { 207 | userToken := []byte(fmt.Sprintf("%s:%s", c.UserName, c.Token)) 208 | return map[string]string{ 209 | "Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString(userToken)), 210 | } 211 | } 212 | 213 | func getCommentsParams(start int) map[string]string { 214 | return map[string]string{ 215 | "start": strconv.Itoa(start), 216 | "limit": strconv.Itoa(LIMIT), 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /pkg/commenter/bitbucket/bitbucket.go: -------------------------------------------------------------------------------- 1 | package bitbucket 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils" 14 | 15 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 16 | ) 17 | 18 | type Bitbucket struct { 19 | Token string 20 | UserName string 21 | Repo string 22 | PrNumber string 23 | ApiUrl string 24 | } 25 | type CommentsResponse struct { 26 | Values []Value `json:"values,omitempty"` 27 | Next string `json:"next"` 28 | } 29 | 30 | type Value struct { 31 | Id int `json:"id,omitempty"` 32 | Deleted bool `json:"deleted,omitempty"` 33 | Content Content `json:"content,omitempty"` 34 | Inline Inline `json:"inline,omitempty"` 35 | } 36 | 37 | type Content struct { 38 | Raw string `json:"raw,omitempty"` 39 | } 40 | 41 | type Inline struct { 42 | From int `json:"from,omitempty"` 43 | To int `json:"to,omitempty"` 44 | Path string `json:"path,omitempty"` 45 | } 46 | 47 | func CreateClient(userName, token, prNumber, repoName string) (b *Bitbucket, err error) { 48 | 49 | apiUrl := os.Getenv("BITBUCKET_API_URL") 50 | if apiUrl == "" { 51 | apiUrl = "https://api.bitbucket.org/2.0/repositories" 52 | } 53 | 54 | fmt.Println("Creating Bitbucket client parameters:") 55 | fmt.Println("apiUrl: ", apiUrl) 56 | fmt.Println("userName: ", userName) 57 | fmt.Println("prNumber: ", prNumber) 58 | fmt.Println("repo: ", repoName) 59 | 60 | return &Bitbucket{ 61 | ApiUrl: apiUrl, 62 | Token: token, 63 | UserName: userName, 64 | PrNumber: prNumber, 65 | Repo: repoName, 66 | }, nil 67 | } 68 | 69 | func NewBitbucket(userName, token string) (b *Bitbucket, err error) { 70 | return CreateClient(userName, token, os.Getenv("BITBUCKET_PR_ID"), os.Getenv("BITBUCKET_REPO_FULL_NAME")) 71 | } 72 | 73 | // WriteMultiLineComment writes a multiline review on a file in the bitbucket PR 74 | func (c *Bitbucket) WriteMultiLineComment(file, comment string, startLine, _ int) error { 75 | // In bitbucket we support one line only 76 | err := c.WriteLineComment(file, comment, startLine) 77 | if err != nil { 78 | return fmt.Errorf("failed to write bitbucket multi line comment: %w", err) 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // WriteLineComment writes a single review line on a file of the bitbucket PR 85 | func (c *Bitbucket) WriteLineComment(file, comment string, line int) error { 86 | if line == commenter.FIRST_AVAILABLE_LINE { 87 | line = 1 88 | } 89 | b := Value{ 90 | Content: Content{Raw: comment}, 91 | Inline: Inline{ 92 | To: line, 93 | Path: file, 94 | }, 95 | } 96 | reqBody, err := json.Marshal(b) 97 | if err != nil { 98 | return fmt.Errorf("failed to marshal body for bitbucket api: %s", err) 99 | } 100 | 101 | client := &http.Client{} 102 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s/pullrequests/%s/comments", 103 | c.ApiUrl, c.Repo, c.PrNumber), 104 | strings.NewReader(string(reqBody))) 105 | if err != nil { 106 | return err 107 | } 108 | req.Header.Add("Content-Type", "application/json") 109 | req.SetBasicAuth(c.UserName, c.Token) 110 | 111 | resp, err := client.Do(req) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if resp.StatusCode != http.StatusCreated { 117 | b, _ := io.ReadAll(resp.Body) 118 | return fmt.Errorf("failed write bitbucket line comment: %s", string(b)) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (c *Bitbucket) getIdsToRemove(commentIdsToRemove []int, msg string, url string) ([]int, error) { 125 | resp, err := utils.GetComments(url, map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(c.UserName+":"+c.Token))}) 126 | if err != nil { 127 | return nil, fmt.Errorf("failed getting comments with error: %w", err) 128 | } 129 | body, err := io.ReadAll(resp.Body) 130 | if err != nil { 131 | return nil, err 132 | } 133 | defer resp.Body.Close() 134 | 135 | commentsResponse := CommentsResponse{} 136 | err = json.Unmarshal(body, &commentsResponse) 137 | if err != nil { 138 | return nil, fmt.Errorf("failed unmarshal response body with error: %w", err) 139 | } 140 | 141 | for _, value := range commentsResponse.Values { 142 | if !value.Deleted && strings.Contains(value.Content.Raw, msg) { 143 | commentIdsToRemove = append(commentIdsToRemove, value.Id) 144 | } 145 | } 146 | 147 | if commentsResponse.Next == "" { 148 | return commentIdsToRemove, nil 149 | } 150 | return c.getIdsToRemove(commentIdsToRemove, msg, commentsResponse.Next) 151 | 152 | } 153 | 154 | func (c *Bitbucket) RemovePreviousAquaComments(msg string) error { 155 | var commentIdsToRemove []int 156 | commentIdsToRemove, err := c.getIdsToRemove(commentIdsToRemove, 157 | msg, fmt.Sprintf("%s/%s/pullrequests/%s/comments", 158 | c.ApiUrl, 159 | c.Repo, 160 | c.PrNumber)) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | for _, commentId := range commentIdsToRemove { 166 | err = utils.DeleteComments( 167 | fmt.Sprintf("%s/%s/pullrequests/%s/comments/%s", 168 | c.ApiUrl, 169 | c.Repo, 170 | c.PrNumber, 171 | strconv.Itoa(commentId)), 172 | map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(c.UserName+":"+c.Token))}) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /pkg/commenter/commenter.go: -------------------------------------------------------------------------------- 1 | package commenter 2 | 3 | type Repository interface { 4 | // WriteMultiLineComment writes a multiline review on a file in the git PR 5 | WriteMultiLineComment(file, comment string, startLine, endLine int) error 6 | // WriteLineComment writes a single review line on a file of the git PR 7 | WriteLineComment(file, comment string, line int) error 8 | // RemovePreviousAquaComments Removing the comments from previous PRs 9 | RemovePreviousAquaComments(msg string) error 10 | } 11 | 12 | var FIRST_AVAILABLE_LINE = -1 13 | -------------------------------------------------------------------------------- /pkg/commenter/github/commitFileInfo.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/samber/lo" 8 | ) 9 | 10 | type chunkLines struct { 11 | Start int 12 | End int 13 | } 14 | 15 | type commitFileInfo struct { 16 | FileName string 17 | ChunkLines []chunkLines 18 | sha string 19 | likelyBinary bool 20 | } 21 | 22 | func (cl *chunkLines) Contains(line int) bool { 23 | return line >= cl.Start && line <= cl.End 24 | } 25 | 26 | func getCommitFileInfo(ghConnector *connector) ([]*commitFileInfo, error) { 27 | 28 | prFiles, err := ghConnector.getFilesForPr() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | var ( 34 | errs []string 35 | commitFileInfos []*commitFileInfo 36 | ) 37 | 38 | for _, file := range prFiles { 39 | info, err := getCommitInfo(file) 40 | if err != nil { 41 | errs = append(errs, err.Error()) 42 | continue 43 | } 44 | commitFileInfos = append(commitFileInfos, info) 45 | } 46 | if len(errs) > 0 { 47 | return nil, fmt.Errorf("there were errors processing the PR files.\n%s", strings.Join(errs, "\n")) 48 | } 49 | return commitFileInfos, nil 50 | } 51 | 52 | func (cfi commitFileInfo) calculatePosition(line int) *int { 53 | ch, _ := lo.Find(cfi.ChunkLines, func(lines chunkLines) bool { 54 | return lines.Contains(line) 55 | }) 56 | 57 | position := line - ch.Start 58 | return &position 59 | } 60 | 61 | func (cfi commitFileInfo) isBinary() bool { 62 | return cfi.likelyBinary 63 | } 64 | 65 | func (cfi commitFileInfo) isResolvable() bool { 66 | return cfi.isBinary() 67 | } 68 | -------------------------------------------------------------------------------- /pkg/commenter/github/connector.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/go-github/v44/github" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | const githubAbuseErrorRetries = 6 13 | 14 | type connector struct { 15 | prs *github.PullRequestsService 16 | comments *github.IssuesService 17 | owner string 18 | repo string 19 | prNumber int 20 | } 21 | 22 | type existingComment struct { 23 | filename *string 24 | comment *string 25 | commentId *int64 26 | } 27 | 28 | type commentFn func() (*github.Response, error) 29 | 30 | // create github connector and check if supplied pr number exists 31 | func createConnector(apiUrl, token, owner, repo string, prNumber int, isEnterprise bool) (*connector, error) { 32 | 33 | client, err := newGithubClient(apiUrl, token, isEnterprise) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if _, _, err := client.PullRequests.Get(context.Background(), owner, repo, prNumber); err != nil { 38 | return nil, newPrDoesNotExistError(owner, repo, prNumber) 39 | } 40 | 41 | return &connector{ 42 | prs: client.PullRequests, 43 | comments: client.Issues, 44 | owner: owner, 45 | repo: repo, 46 | prNumber: prNumber, 47 | }, nil 48 | } 49 | 50 | func newGithubClient(apiUrl, token string, isEnterprise bool) (*github.Client, error) { 51 | 52 | ctx := context.Background() 53 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 54 | tc := oauth2.NewClient(ctx, ts) 55 | 56 | if isEnterprise { 57 | return github.NewEnterpriseClient(apiUrl, apiUrl, tc) 58 | } 59 | 60 | return github.NewClient(tc), nil 61 | } 62 | 63 | func (c *connector) writeReviewComment(block *github.PullRequestComment, commentId *int64) error { 64 | 65 | ctx := context.Background() 66 | if commentId != nil { 67 | return writeCommentWithRetries(c.owner, c.repo, c.prNumber, func() (*github.Response, error) { 68 | _, resp, err := c.prs.EditComment(ctx, c.owner, c.repo, *commentId, &github.PullRequestComment{ 69 | Body: block.Body, 70 | }) 71 | return resp, err 72 | }) 73 | } 74 | 75 | return writeCommentWithRetries(c.owner, c.repo, c.prNumber, func() (*github.Response, error) { 76 | _, resp, err := c.prs.CreateComment(ctx, c.owner, c.repo, c.prNumber, block) 77 | return resp, err 78 | }) 79 | } 80 | 81 | func writeCommentWithRetries(owner, repo string, prNumber int, commentFn commentFn) error { 82 | 83 | var abuseError AbuseRateLimitError 84 | for i := 0; i < githubAbuseErrorRetries; i++ { 85 | 86 | retrySeconds := i * i 87 | time.Sleep(time.Second * time.Duration(retrySeconds)) 88 | 89 | if resp, err := commentFn(); err != nil { 90 | // If we get a 403 or 422, we are being rate or abuse limited by GitHub, 91 | // and we want to retry, while increasing the wait time between retries. 92 | if resp != nil && (resp.StatusCode == 422 || resp.StatusCode == 403) { 93 | abuseError = newAbuseRateLimitError(owner, repo, prNumber, retrySeconds) 94 | continue 95 | } 96 | return fmt.Errorf("write comment: %v", err) 97 | } 98 | return nil 99 | } 100 | return abuseError 101 | } 102 | 103 | func (c *connector) getFilesForPr() ([]*github.CommitFile, error) { 104 | 105 | files, _, err := c.prs.ListFiles(context.Background(), c.owner, c.repo, c.prNumber, nil) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | var commitFiles []*github.CommitFile 111 | for _, file := range files { 112 | if *file.Status != "deleted" { 113 | commitFiles = append(commitFiles, file) 114 | } 115 | } 116 | return commitFiles, nil 117 | } 118 | 119 | func (c *connector) getExistingComments() ([]*existingComment, error) { 120 | 121 | ctx := context.Background() 122 | comments, _, err := c.prs.ListComments(ctx, c.owner, c.repo, c.prNumber, &github.PullRequestListCommentsOptions{}) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | var existingComments []*existingComment 128 | for _, comment := range comments { 129 | existingComments = append(existingComments, &existingComment{ 130 | filename: comment.Path, 131 | comment: comment.Body, 132 | commentId: comment.ID, 133 | }) 134 | } 135 | return existingComments, nil 136 | } 137 | -------------------------------------------------------------------------------- /pkg/commenter/github/errors.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "fmt" 4 | 5 | // CommentAlreadyWrittenError returned when the error can't be written as it already exists 6 | type CommentAlreadyWrittenError struct { 7 | filepath string 8 | comment string 9 | } 10 | 11 | // CommentNotValidError returned when the comment is for a file or line not in the pr 12 | type CommentNotValidError struct { 13 | filepath string 14 | lineNo int 15 | } 16 | 17 | // PrDoesNotExistError returned when the PR can't be found, either as 401 or not existing 18 | type PrDoesNotExistError struct { 19 | owner string 20 | repo string 21 | prNumber int 22 | } 23 | 24 | // AbuseRateLimitError return when the GitHub abuse rate limit is hit 25 | type AbuseRateLimitError struct { 26 | owner string 27 | repo string 28 | prNumber int 29 | BackoffInSeconds int 30 | } 31 | 32 | func (e CommentAlreadyWrittenError) Error() string { 33 | return fmt.Sprintf("The file [%s] already has the comment written [%s]", e.filepath, e.comment) 34 | } 35 | 36 | func newCommentNotValidError(filepath string, line int) CommentNotValidError { 37 | return CommentNotValidError{ 38 | filepath: filepath, 39 | lineNo: line, 40 | } 41 | } 42 | 43 | func (e CommentNotValidError) Error() string { 44 | return fmt.Sprintf("There is nothing to comment on at line [%d] in file [%s]", e.lineNo, e.filepath) 45 | } 46 | 47 | func newPrDoesNotExistError(owner, repo string, prNumber int) PrDoesNotExistError { 48 | return PrDoesNotExistError{ 49 | owner: owner, 50 | repo: repo, 51 | prNumber: prNumber, 52 | } 53 | } 54 | 55 | func (e PrDoesNotExistError) Error() string { 56 | return fmt.Sprintf("PR number [%d] not found for %s/%s", e.prNumber, e.owner, e.repo) 57 | } 58 | 59 | func newAbuseRateLimitError(owner, repo string, prNumber, backoffInSeconds int) AbuseRateLimitError { 60 | return AbuseRateLimitError{ 61 | owner: owner, 62 | repo: repo, 63 | prNumber: prNumber, 64 | BackoffInSeconds: backoffInSeconds, 65 | } 66 | } 67 | 68 | func (e AbuseRateLimitError) Error() string { 69 | return fmt.Sprintf("Abuse limit reached on PR [%d] not found for %s/%s", e.prNumber, e.owner, e.repo) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/commenter/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 11 | "github.com/google/go-github/v44/github" 12 | "github.com/samber/lo" 13 | ) 14 | 15 | type Github struct { 16 | ghConnector *connector 17 | existingComments []*existingComment 18 | files []*commitFileInfo 19 | Token string 20 | Owner string 21 | Repo string 22 | PrNumber int 23 | } 24 | 25 | var ( 26 | patchRegex = regexp.MustCompile(`@@.*\d [\+\-](\d+),?(\d+)?.+?@@`) 27 | commitRefRegex = regexp.MustCompile(".+ref=(.+)") 28 | ) 29 | 30 | func NewGithub(token, owner, repo string, prNumber int) (gh *Github, err error) { 31 | if len(token) == 0 { 32 | return gh, fmt.Errorf("failed GITHUB_TOKEN has not been set") 33 | } 34 | ghConnector, err := createConnector("", token, owner, repo, prNumber, false) 35 | if err != nil { 36 | return gh, fmt.Errorf("failed create github connector: %w", err) 37 | } 38 | commitFileInfos, existingComments, err := loadPr(ghConnector) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed load pr: %w", err) 41 | } 42 | return &Github{ 43 | Token: token, 44 | Owner: owner, 45 | PrNumber: prNumber, 46 | Repo: repo, 47 | ghConnector: ghConnector, 48 | files: commitFileInfos, 49 | existingComments: existingComments, 50 | }, nil 51 | } 52 | 53 | func NewGithubServer(apiUrl, token, owner, repo string, prNumber int) (gh *Github, err error) { 54 | if len(token) == 0 { 55 | return gh, fmt.Errorf("failed GITHUB_TOKEN has not been set, for github Enterprise") 56 | } 57 | ghConnector, err := createConnector(apiUrl, token, owner, repo, prNumber, true) 58 | if err != nil { 59 | return gh, fmt.Errorf("failed create github connector, for github Enterprise: %w", err) 60 | } 61 | commitFileInfos, existingComments, err := loadPr(ghConnector) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed load pr, for github Enterprise: %w", err) 64 | } 65 | return &Github{ 66 | Token: token, 67 | Owner: owner, 68 | PrNumber: prNumber, 69 | Repo: repo, 70 | ghConnector: ghConnector, 71 | files: commitFileInfos, 72 | existingComments: existingComments, 73 | }, nil 74 | } 75 | func loadPr(ghConnector *connector) ([]*commitFileInfo, []*existingComment, error) { 76 | 77 | commitFileInfos, err := getCommitFileInfo(ghConnector) 78 | if err != nil { 79 | return nil, nil, err 80 | } 81 | 82 | existingComments, err := ghConnector.getExistingComments() 83 | if err != nil { 84 | return nil, nil, err 85 | } 86 | return commitFileInfos, existingComments, nil 87 | } 88 | 89 | func getCommitInfo(file *github.CommitFile) (cfi *commitFileInfo, err error) { 90 | var isBinary bool 91 | patch := file.GetPatch() 92 | lines, err := parseChunkPositions(patch, *file.Filename) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | shaGroups := commitRefRegex.FindAllStringSubmatch(file.GetContentsURL(), -1) 98 | if len(shaGroups) < 1 { 99 | return nil, fmt.Errorf("the sha details for [%s] could not be resolved", *file.Filename) 100 | } 101 | sha := shaGroups[0][1] 102 | 103 | return &commitFileInfo{ 104 | FileName: *file.Filename, 105 | ChunkLines: lines, 106 | sha: sha, 107 | likelyBinary: isBinary, 108 | }, nil 109 | } 110 | func parseChunkPositions(patch, filename string) (lines []chunkLines, err error) { 111 | if patch != "" { 112 | groups := patchRegex.FindAllStringSubmatch(patch, -1) 113 | if len(groups) < 1 { 114 | return nil, fmt.Errorf("the patch details for [%s] could not be resolved", filename) 115 | } 116 | 117 | for _, patchGroup := range groups { 118 | endPos := 2 119 | if len(patchGroup) > 2 && patchGroup[2] == "" { 120 | endPos = 1 121 | } 122 | 123 | chunkStart, err := strconv.Atoi(patchGroup[1]) 124 | if err != nil { 125 | chunkStart = -1 126 | } 127 | chunkEnd, err := strconv.Atoi(patchGroup[endPos]) 128 | if err != nil { 129 | chunkEnd = -1 130 | } 131 | 132 | lines = append(lines, chunkLines{chunkStart, chunkStart + (chunkEnd - 1)}) 133 | } 134 | } 135 | return lines, nil 136 | } 137 | 138 | func (c *Github) checkCommentRelevant(filename string, line int) bool { 139 | 140 | for _, file := range c.files { 141 | if relevant := func(file *commitFileInfo) bool { 142 | if file.FileName == filename && !file.isResolvable() { 143 | if (line == commenter.FIRST_AVAILABLE_LINE) || (checkIfLineInChunk(line, file)) { 144 | return true 145 | } 146 | } 147 | return false 148 | }(file); relevant { 149 | return true 150 | } 151 | } 152 | return false 153 | } 154 | 155 | func checkIfLineInChunk(line int, file *commitFileInfo) bool { 156 | if file.FileName == "go.mod" && len(file.ChunkLines) > 0 { 157 | return true 158 | } 159 | 160 | _, found := lo.Find(file.ChunkLines, func(lines chunkLines) bool { 161 | return lines.Contains(line) 162 | }) 163 | 164 | return found 165 | } 166 | 167 | func (c *Github) getFileInfo(file string, line int) (*commitFileInfo, error) { 168 | 169 | for _, info := range c.files { 170 | if info.FileName == file && !info.isResolvable() { 171 | if (line == commenter.FIRST_AVAILABLE_LINE) || (checkIfLineInChunk(line, info)) { 172 | return info, nil 173 | } 174 | } 175 | } 176 | return nil, fmt.Errorf("file not found, shouldn't have got to here") 177 | } 178 | 179 | func getFirstChunkLine(file commitFileInfo) int { 180 | lines := lo.MinBy(file.ChunkLines, func(lines chunkLines, minLines chunkLines) bool { 181 | return lines.Start < minLines.Start 182 | 183 | }) 184 | return lines.Start 185 | } 186 | 187 | func buildComment(file, comment string, line int, info commitFileInfo) *github.PullRequestComment { 188 | if line == commenter.FIRST_AVAILABLE_LINE { 189 | line = getFirstChunkLine(info) 190 | } 191 | 192 | return &github.PullRequestComment{ 193 | Line: &line, 194 | Path: &file, 195 | CommitID: &info.sha, 196 | Body: &comment, 197 | Position: info.calculatePosition(line), 198 | } 199 | } 200 | 201 | func (c *Github) writeCommentIfRequired(prComment *github.PullRequestComment) error { 202 | var commentId *int64 203 | for _, existing := range c.existingComments { 204 | commentId = func(ec *existingComment) *int64 { 205 | if *ec.filename == *prComment.Path && *ec.comment == *prComment.Body { 206 | return ec.commentId 207 | } 208 | return nil 209 | }(existing) 210 | if commentId != nil { 211 | break 212 | } 213 | } 214 | 215 | if err := c.ghConnector.writeReviewComment(prComment, commentId); err != nil { 216 | return fmt.Errorf("write review comment: %w", err) 217 | } 218 | return nil 219 | } 220 | 221 | // WriteMultiLineComment writes a multiline review on a file in the github PR 222 | func (c *Github) WriteMultiLineComment(file, comment string, startLine, endLine int) error { 223 | if startLine == 0 { 224 | startLine = 1 225 | } 226 | if endLine == 0 { 227 | endLine = 1 228 | } 229 | 230 | if endLine < startLine { 231 | startLine++ 232 | endLine = startLine 233 | } 234 | 235 | if !c.checkCommentRelevant(file, startLine) || !c.checkCommentRelevant(file, endLine) { 236 | return newCommentNotValidError(file, startLine) 237 | } 238 | if startLine == endLine { 239 | return c.WriteLineComment(file, comment, endLine) 240 | } 241 | 242 | info, err := c.getFileInfo(file, endLine) 243 | if err != nil { 244 | return err 245 | } 246 | prComment := buildComment(file, comment, endLine, *info) 247 | prComment.StartLine = &startLine 248 | return c.writeCommentIfRequired(prComment) 249 | } 250 | 251 | // WriteLineComment writes a single review line on a file of the github PR 252 | func (c *Github) WriteLineComment(file, comment string, line int) error { 253 | if !c.checkCommentRelevant(file, line) { 254 | return newCommentNotValidError(file, line) 255 | } 256 | info, err := c.getFileInfo(file, line) 257 | if err != nil { 258 | return err 259 | } 260 | prComment := buildComment(file, comment, line, *info) 261 | 262 | return c.writeCommentIfRequired(prComment) 263 | } 264 | 265 | func (c *Github) RemovePreviousAquaComments(msg string) error { 266 | ctx := context.Background() 267 | for _, existing := range c.existingComments { 268 | if strings.Contains(*existing.comment, msg) { 269 | if _, err := c.ghConnector.prs.DeleteComment(ctx, c.Owner, c.Repo, *existing.commentId); err != nil { 270 | return err 271 | } 272 | } 273 | } 274 | c.existingComments = nil 275 | return nil 276 | } 277 | -------------------------------------------------------------------------------- /pkg/commenter/gitlab/gitlab.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils" 15 | "github.com/samber/lo" 16 | 17 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 18 | ) 19 | 20 | type DiscussionNote struct { 21 | DiscussionId string 22 | NoteId int 23 | } 24 | type Discussion struct { 25 | Id string `json:"id,omitempty"` 26 | Notes []Note `json:"notes,omitempty"` 27 | } 28 | 29 | type Note struct { 30 | Id int `json:"id,omitempty"` 31 | Body string `json:"body,omitempty"` 32 | } 33 | 34 | type Version struct { 35 | ID int `json:"id"` 36 | HeadCommitSha string `json:"head_commit_sha"` 37 | BaseCommitSha string `json:"base_commit_sha"` 38 | StartCommitSha string `json:"start_commit_sha"` 39 | CreatedAt time.Time `json:"created_at"` 40 | MergeRequestID int `json:"merge_request_id"` 41 | State string `json:"state"` 42 | RealSize string `json:"real_size"` 43 | } 44 | 45 | type Gitlab struct { 46 | ApiURL string 47 | Token string 48 | Repo string 49 | PrNumber string 50 | } 51 | 52 | var lockFiles = []string{"package.json", "yarn.lock"} 53 | 54 | func NewGitlab(token, apiUrl, repoName, mergeRequestID string) (b *Gitlab, err error) { 55 | return &Gitlab{ 56 | ApiURL: lo.Ternary(apiUrl == "", os.Getenv("CI_API_V4_URL"), apiUrl), 57 | Token: token, 58 | Repo: lo.Ternary(repoName == "", os.Getenv("CI_PROJECT_ID"), repoName), 59 | PrNumber: lo.Ternary(mergeRequestID == "", os.Getenv("CI_MERGE_REQUEST_IID"), mergeRequestID), 60 | }, nil 61 | } 62 | 63 | // WriteMultiLineComment writes a multiline review on a file in the gitlab PR 64 | func (c *Gitlab) WriteMultiLineComment(file, comment string, startLine, _ int) error { 65 | // In gitlab we support one line only 66 | err := c.WriteLineComment(file, comment, startLine) 67 | if err != nil { 68 | return fmt.Errorf("failed write gitlab multi line comment: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // WriteLineComment writes a single review line on a file of the gitlab PR 75 | func (c *Gitlab) WriteLineComment(file, comment string, line int) error { 76 | if line == 0 { 77 | line = 1 78 | } 79 | 80 | version, err := c.getLatestVersion() 81 | if err != nil { 82 | return fmt.Errorf("failed get latest version: %w", err) 83 | } 84 | urlValues := url.Values{ 85 | "position[position_type]": {"text"}, 86 | "position[base_sha]": {version.BaseCommitSha}, 87 | "position[head_sha]": {version.HeadCommitSha}, 88 | "position[start_sha]": {version.StartCommitSha}, 89 | "position[new_path]": {file}, 90 | "position[old_path]": {file}, 91 | "position[new_line]": {strconv.Itoa(line)}, 92 | "body": {comment}, 93 | } 94 | 95 | if line == commenter.FIRST_AVAILABLE_LINE { 96 | line = 1 97 | urlValues["position[new_line]"] = []string{strconv.Itoa(line)} 98 | } 99 | 100 | client := &http.Client{} 101 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/projects/%s/merge_requests/%s/discussions", 102 | c.ApiURL, c.Repo, c.PrNumber), 103 | strings.NewReader(urlValues.Encode())) 104 | if err != nil { 105 | return err 106 | } 107 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 108 | req.Header.Add("PRIVATE-TOKEN", c.Token) 109 | resp, err := client.Do(req) 110 | if err != nil { 111 | return err 112 | } 113 | if resp.StatusCode != http.StatusCreated { 114 | fmt.Printf("failed to write comment to file: %s, trying again... \n", file) 115 | urlValues["position[old_line]"] = []string{strconv.Itoa(line)} 116 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/projects/%s/merge_requests/%s/discussions", 117 | c.ApiURL, c.Repo, c.PrNumber), 118 | strings.NewReader(urlValues.Encode())) 119 | if err != nil { 120 | return err 121 | } 122 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 123 | req.Header.Add("PRIVATE-TOKEN", c.Token) 124 | resp, err := client.Do(req) 125 | if err != nil { 126 | return err 127 | } 128 | if resp.StatusCode != http.StatusCreated { 129 | if lo.ContainsBy(lockFiles, func(lf string) bool { 130 | return strings.Contains(string(file), lf) 131 | }) { 132 | resp, err := c.writeGeneralPrComment(file, comment) 133 | if err != nil { 134 | return err 135 | } else if resp.StatusCode == http.StatusCreated { 136 | fmt.Println("comment created successfully") 137 | return nil 138 | } 139 | } 140 | b, _ := io.ReadAll(resp.Body) 141 | return fmt.Errorf("failed to write comment to file: %s, on line: %d, with gitlab error: %s", file, line, string(b)) 142 | } 143 | 144 | fmt.Println("comment created successfully") 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (c *Gitlab) writeGeneralPrComment(file, comment string) (*http.Response, error) { 151 | client := &http.Client{} 152 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/projects/%s/merge_requests/%s/notes", 153 | c.ApiURL, c.Repo, c.PrNumber), 154 | strings.NewReader(url.Values{"body": {expendComment(comment, file)}}.Encode())) 155 | if err != nil { 156 | return nil, err 157 | } 158 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 159 | req.Header.Add("PRIVATE-TOKEN", c.Token) 160 | resp, err := client.Do(req) 161 | if err != nil { 162 | return nil, err 163 | } 164 | return resp, nil 165 | } 166 | 167 | func expendComment(comment, file string) string { 168 | return fmt.Sprintf("%s\n\n %s\n %s", "_The comment could not be added to the file because the size of the source diff is too large._", fmt.Sprintf("**File Path:** `%s`", file), comment) 169 | } 170 | 171 | func (c *Gitlab) getLatestVersion() (v Version, err error) { 172 | var vData []Version 173 | 174 | client := &http.Client{} 175 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/projects/%s/merge_requests/%s/versions", 176 | c.ApiURL, c.Repo, c.PrNumber), nil) 177 | if err != nil { 178 | return v, err 179 | } 180 | req.Header.Add("PRIVATE-TOKEN", c.Token) 181 | resp, err := client.Do(req) 182 | if err != nil { 183 | return v, err 184 | } 185 | if resp.StatusCode != http.StatusOK { 186 | b, _ := io.ReadAll(resp.Body) 187 | return v, fmt.Errorf("failed get gitlab PR version: %s", string(b)) 188 | } 189 | defer func() { _ = resp.Body.Close() }() 190 | err = json.NewDecoder(resp.Body).Decode(&vData) 191 | if err != nil { 192 | return v, fmt.Errorf("failed decoding gitlab version response with error: %w", err) 193 | } 194 | 195 | if len(vData) > 0 { 196 | v = vData[0] 197 | } 198 | return v, nil 199 | } 200 | 201 | func (c *Gitlab) RemovePreviousAquaComments(msg string) error { 202 | 203 | var idsToRemove []DiscussionNote 204 | idsToRemove, err := c.getIdsToRemove(idsToRemove, msg, "1") 205 | if err != nil { 206 | return err 207 | } 208 | 209 | for _, idToRemove := range idsToRemove { 210 | err = utils.DeleteComments(fmt.Sprintf("%s/projects/%s/merge_requests/%s/discussions/%s/notes/%s", 211 | c.ApiURL, c.Repo, c.PrNumber, idToRemove.DiscussionId, strconv.Itoa(idToRemove.NoteId)), map[string]string{"PRIVATE-TOKEN": c.Token}) 212 | if err != nil { 213 | return err 214 | } 215 | } 216 | 217 | return nil 218 | 219 | } 220 | 221 | func (c *Gitlab) getIdsToRemove(idsToRemove []DiscussionNote, msg, page string) ([]DiscussionNote, error) { 222 | resp, err := utils.GetComments( 223 | fmt.Sprintf("%s/projects/%s/merge_requests/%s/discussions?page=%s", 224 | c.ApiURL, 225 | c.Repo, 226 | c.PrNumber, 227 | page), 228 | map[string]string{"PRIVATE-TOKEN": c.Token}) 229 | if err != nil { 230 | return nil, fmt.Errorf("failed getting comments with error: %w", err) 231 | } 232 | body, err := io.ReadAll(resp.Body) 233 | if err != nil { 234 | return nil, err 235 | } 236 | defer resp.Body.Close() 237 | 238 | var discussionsResponse []Discussion 239 | err = json.Unmarshal(body, &discussionsResponse) 240 | if err != nil { 241 | return nil, fmt.Errorf("failed unmarshal response body with error: %w", err) 242 | } 243 | 244 | for _, discussion := range discussionsResponse { 245 | for _, note := range discussion.Notes { 246 | if strings.Contains(note.Body, msg) { 247 | idsToRemove = append(idsToRemove, DiscussionNote{ 248 | DiscussionId: discussion.Id, 249 | NoteId: note.Id, 250 | }) 251 | } 252 | } 253 | } 254 | 255 | if resp.Header.Get("x-next-page") == "" { 256 | return idsToRemove, nil 257 | } 258 | return c.getIdsToRemove(idsToRemove, msg, resp.Header.Get("x-next-page")) 259 | 260 | } 261 | -------------------------------------------------------------------------------- /pkg/commenter/jenkins/jenkins.go: -------------------------------------------------------------------------------- 1 | package jenkins 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/github" 11 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/gitlab" 12 | 13 | "github.com/argonsecurity/go-environments/enums" 14 | "github.com/argonsecurity/go-environments/environments/jenkins" 15 | env_utils "github.com/argonsecurity/go-environments/environments/utils" 16 | 17 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter" 18 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/bitbucket" 19 | bitbucket_server "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/bitbucket-server" 20 | "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils" 21 | bitbucketutils "github.com/aquasecurity/go-git-pr-commenter/pkg/commenter/utils/bitbucket" 22 | ) 23 | 24 | func NewJenkins(baseRef string) (commenter.Repository, error) { 25 | cloneUrl, _ := utils.GetRepositoryCloneURL() 26 | sanitizedCloneUrl := env_utils.StripCredentialsFromUrl(cloneUrl) 27 | scmSource, scmApiUrl := jenkins.GetRepositorySource(sanitizedCloneUrl) 28 | 29 | if _, exists := bitbucketutils.GetBitbucketPayload(); strings.Contains(cloneUrl, "bitbucket") || exists { 30 | username, ok := os.LookupEnv("USERNAME") 31 | if !ok { 32 | return nil, fmt.Errorf("USERNAME env var is not set") 33 | } 34 | password, ok := os.LookupEnv("PASSWORD") 35 | if !ok { 36 | return nil, fmt.Errorf("PASSWORD env var is not set") 37 | } 38 | 39 | if strings.Contains(cloneUrl, "bitbucket.org") { 40 | return bitbucket.CreateClient(username, password, bitbucketutils.GetPrId(), bitbucketutils.GetRepositoryName(cloneUrl)) 41 | } else { // bitbucket server 42 | repoName := bitbucketutils.GetRepositoryName(cloneUrl) 43 | project, repo := bitbucketutils.GetProjectAndRepo(repoName) 44 | return bitbucket_server.NewBitbucketServer(scmApiUrl, username, password, bitbucketutils.GetPrId(), project, repo, baseRef) 45 | } 46 | } else if scmSource == enums.GithubServer || scmSource == enums.Github { 47 | _, org, repoName, _, err := env_utils.ParseDataFromCloneUrl(cloneUrl, scmApiUrl, scmSource) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed parsing url with error: %s", err.Error()) 50 | } 51 | token := os.Getenv("GITHUB_TOKEN") 52 | prNumber := os.Getenv("CHANGE_ID") 53 | // for gh single jenkins pipeline 54 | if prNumber == "" { 55 | prNumber = os.Getenv("ghprbPullId") 56 | } 57 | prNumberInt, err := strconv.Atoi(prNumber) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed converting prNumber to int, %s: %s", prNumber, err.Error()) 60 | } 61 | 62 | if scmSource == enums.Github { 63 | return github.NewGithub( 64 | token, 65 | org, 66 | repoName, 67 | prNumberInt) 68 | } else { //github server 69 | return github.NewGithubServer(scmApiUrl, token, org, repoName, prNumberInt) 70 | } 71 | 72 | } else if scmSource == enums.GitlabServer || scmSource == enums.Gitlab { 73 | _, org, repoName, _, err := env_utils.ParseDataFromCloneUrl(cloneUrl, scmApiUrl, scmSource) 74 | if err != nil { 75 | return nil, fmt.Errorf("failed parsing url with error: %s", err.Error()) 76 | } 77 | token := os.Getenv("GITLAB_TOKEN") 78 | prNumber := os.Getenv("CHANGE_ID") 79 | 80 | return gitlab.NewGitlab(token, scmApiUrl, url.PathEscape(fmt.Sprintf("%s/%s", org, repoName)), prNumber) 81 | } 82 | return nil, nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/commenter/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | type Mock struct{} 4 | 5 | func NewMock() *Mock { 6 | return &Mock{} 7 | } 8 | 9 | func (c *Mock) WriteMultiLineComment(_, _ string, _, _ int) error { 10 | return nil 11 | } 12 | 13 | func (c *Mock) WriteLineComment(_, _ string, _ int) error { 14 | return nil 15 | } 16 | 17 | func (c *Mock) RemovePreviousAquaComments(_ string) error { 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/commenter/utils/bitbucket/bitbucket-utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | type bitbucketRepositoryOwnerPyaload struct { 12 | DisplayName string `json:"display_name"` 13 | } 14 | 15 | type bitbucketRepositoryPayload struct { 16 | Name string 17 | Owner bitbucketRepositoryOwnerPyaload 18 | } 19 | 20 | type bitbucketPayload struct { 21 | Repository bitbucketRepositoryPayload 22 | } 23 | 24 | func GetPrId() string { 25 | if id, exists := os.LookupEnv("BITBUCKET_PULL_REQUEST_ID"); exists { 26 | fmt.Println("Using pull request id from BITBUCKET_PULL_REQUEST_ID: ", id) 27 | return id 28 | } 29 | 30 | if id, exists := os.LookupEnv("CHANGE_ID"); exists { 31 | fmt.Println("Using pull request id from CHANGE_ID: ", id) 32 | return id 33 | } 34 | 35 | fmt.Println("Could not find pull request id") 36 | return "" 37 | } 38 | 39 | func GetRepositoryName(cloneUrl string) string { 40 | payload, exists := GetBitbucketPayload() 41 | if exists { 42 | name := payload.Repository.Owner.DisplayName + "/" + payload.Repository.Name 43 | fmt.Println("Using repository name from BITBUCKET_PAYLOAD: ", name) 44 | return name 45 | } 46 | 47 | nameRegexp := regexp.MustCompile(`(([^\/]+)\/([^\/]+))(?:\.git)$`) 48 | matches := nameRegexp.FindStringSubmatch(cloneUrl) 49 | if len(matches) > 1 { 50 | name := nameRegexp.FindStringSubmatch(cloneUrl)[1] 51 | fmt.Println("Using repository name from cloneUrl: ", name) 52 | return name 53 | } 54 | 55 | fmt.Println("Could not find repository name") 56 | return "" 57 | } 58 | 59 | func GetProjectAndRepo(repoName string) (string, string) { 60 | project, repo := "", "" 61 | if repoName != "" { 62 | split := strings.Split(repoName, "/") 63 | if len(split) == 2 { 64 | project, repo = split[0], split[1] 65 | } 66 | } 67 | return project, repo 68 | } 69 | 70 | func GetBitbucketPayload() (*bitbucketPayload, bool) { 71 | rawPayload, exists := os.LookupEnv("BITBUCKET_PAYLOAD") 72 | if !exists { 73 | return nil, false 74 | } 75 | 76 | payload := &bitbucketPayload{} 77 | err := json.Unmarshal([]byte(rawPayload), payload) 78 | if err != nil { 79 | return nil, false 80 | } 81 | 82 | return payload, true 83 | } 84 | -------------------------------------------------------------------------------- /pkg/commenter/utils/change-report/change-report.go: -------------------------------------------------------------------------------- 1 | package change_report 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type ChangeType string 12 | 13 | var ( 14 | ADDED ChangeType = "ADDED" 15 | REMOVED ChangeType = "REMOVED" 16 | CONTEXT ChangeType = "CONTEXT" 17 | ) 18 | 19 | type FileChange struct { 20 | AddedLines map[int]bool 21 | } 22 | 23 | type ChangeReport map[string]*FileChange 24 | 25 | func GenerateChangeReport(baseRef string) (ChangeReport, error) { 26 | out, err := gitExec("diff", baseRef) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to get git diff: %w", err) 29 | } 30 | report, err := parseDiff(string(out)) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to parse git diff: %w", err) 33 | } 34 | 35 | return *report, nil 36 | } 37 | 38 | func parseDiff(diffString string) (*ChangeReport, error) { 39 | diff := make(ChangeReport) 40 | var file *FileChange 41 | var lineCount int 42 | var inHunk bool 43 | newFilePrefix := "+++ b/" 44 | isFileDeleted := false 45 | 46 | lines := strings.Split(diffString, "\n") 47 | for _, line := range lines { 48 | switch { 49 | case strings.HasPrefix(line, "diff "): 50 | inHunk = false 51 | isFileDeleted = false 52 | file = &FileChange{} 53 | case isFileDeleted: 54 | continue 55 | case line == "+++ /dev/null": 56 | isFileDeleted = true 57 | case strings.HasPrefix(line, newFilePrefix): 58 | filename := strings.TrimPrefix(line, newFilePrefix) 59 | file.AddedLines = make(map[int]bool) 60 | diff[filename] = file 61 | case strings.HasPrefix(line, "@@ "): 62 | inHunk = true 63 | 64 | re := regexp.MustCompile(`@@ \-(\d+),?(\d+)? \+(\d+),?(\d+)? @@`) 65 | m := re.FindStringSubmatch(line) 66 | if len(m) < 4 { 67 | return nil, fmt.Errorf("error parsing line: %s", line) 68 | } 69 | diffStartLine, err := strconv.Atoi(m[3]) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | lineCount = diffStartLine 75 | case inHunk && isSourceLine(line): 76 | t, err := getChangeType(line) 77 | if err != nil { 78 | return nil, err 79 | } 80 | if *t != REMOVED { 81 | if *t == ADDED { 82 | file.AddedLines[lineCount] = true 83 | } 84 | lineCount++ 85 | } 86 | } 87 | } 88 | 89 | return &diff, nil 90 | } 91 | 92 | func gitExec(args ...string) ([]byte, error) { 93 | cmd := exec.Command("git", args...) 94 | output, err := cmd.CombinedOutput() 95 | if err != nil { 96 | return nil, fmt.Errorf("failed run git cmd output: %w", err) 97 | } 98 | 99 | return output, nil 100 | } 101 | 102 | func isSourceLine(line string) bool { 103 | if line == `\ No newline at end of file` { 104 | return false 105 | } 106 | if l := len(line); l == 0 || (l >= 3 && (line[:3] == "---" || line[:3] == "+++")) { 107 | return false 108 | } 109 | return true 110 | } 111 | 112 | func getChangeType(line string) (*ChangeType, error) { 113 | var t ChangeType 114 | switch line[:1] { 115 | case " ": 116 | t = CONTEXT 117 | case "+": 118 | t = ADDED 119 | case "-": 120 | t = REMOVED 121 | default: 122 | return nil, fmt.Errorf("failed to parse line mode for line: '%s'", line) 123 | } 124 | return &t, nil 125 | } 126 | -------------------------------------------------------------------------------- /pkg/commenter/utils/comment_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | func UrlWithParams(baseUrl string, params map[string]string) (string, error) { 14 | newUrl, err := url.Parse(baseUrl) 15 | if err != nil { 16 | return "", err 17 | } 18 | q := newUrl.Query() 19 | for key, value := range params { 20 | q.Add(key, value) 21 | } 22 | newUrl.RawQuery = q.Encode() 23 | return newUrl.String(), nil 24 | } 25 | 26 | func DeleteComments(url string, headers map[string]string) error { 27 | client := &http.Client{} 28 | req, err := http.NewRequest(http.MethodDelete, url, nil) 29 | if err != nil { 30 | return err 31 | } 32 | req.Header.Add("Content-Type", "application/json") 33 | for key, value := range headers { 34 | req.Header.Add(key, value) 35 | } 36 | _, err = client.Do(req) 37 | if err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | 43 | func GetComments(url string, headers map[string]string) (*http.Response, error) { 44 | client := &http.Client{} 45 | 46 | req, err := http.NewRequest(http.MethodGet, url, nil) 47 | if err != nil { 48 | return nil, err 49 | } 50 | req.Header.Set("Accept", "application/json") 51 | for key, value := range headers { 52 | req.Header.Add(key, value) 53 | } 54 | return client.Do(req) 55 | } 56 | 57 | func GetRepositoryCloneURL() (string, error) { 58 | if cloneUrl, isExist := os.LookupEnv("GIT_URL"); isExist { 59 | fmt.Println("Using GIT_URL env var as clone url: ", cloneUrl) 60 | return cloneUrl, nil 61 | } 62 | 63 | return getGitRemoteURL() 64 | } 65 | 66 | func getGitRemoteURL() (string, error) { 67 | repositoryPath, ok := os.LookupEnv("WORKSPACE") 68 | if !ok { 69 | return "", errors.New("could not find remote url, no WORKSPACE env var") 70 | } 71 | remotes, err := getGitRemotes(repositoryPath) 72 | if err != nil { 73 | return "", fmt.Errorf("failed to get git remotes: %w", err) 74 | } 75 | 76 | if len(remotes) == 0 { 77 | return "", errors.New("no git remotes found") 78 | } 79 | 80 | remoteUrl := remotes[0][1] 81 | 82 | for _, remote := range remotes { 83 | if remote[0] == "origin" { 84 | remoteUrl = remote[1] 85 | break 86 | } 87 | } 88 | 89 | fmt.Println("Using remote url extracted from WORKSPACE: ", remoteUrl) 90 | 91 | return remoteUrl, nil 92 | } 93 | 94 | func getGitRemotes(repositoryPath string) ([][]string, error) { 95 | gitPath, err := exec.LookPath("git") 96 | if err != nil { 97 | return nil, errors.New("git not found") 98 | } 99 | 100 | cmd := exec.Command(gitPath, "remote", "-v") 101 | cmd.Dir = repositoryPath 102 | output, err := cmd.Output() 103 | if err != nil { 104 | return nil, fmt.Errorf(`failed to execute git command "%s" - %s - %s`, cmd.String(), output, err.Error()) 105 | } 106 | 107 | outputAsString := string(output) 108 | outputAsString = strings.TrimSuffix(outputAsString, "\n") 109 | lines := strings.Split(outputAsString, "\n") 110 | remotes := [][]string{} 111 | for _, line := range lines { 112 | remotes = append(remotes, strings.Fields(line)) 113 | } 114 | return remotes, nil 115 | } 116 | -------------------------------------------------------------------------------- /release.yaml: -------------------------------------------------------------------------------- 1 | name: "commenter" 2 | repository: github.com/aquasecurity/go-git-pr-commenter 3 | version: "v0.8.9" 4 | usage: commenter cmd 5 | description: commenter adding comments to git PRs 6 | platforms: 7 | - selector: # optional 8 | os: linux 9 | arch: amd64 10 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/v0.8.9/linux_amd64_v0.8.9.tar.gz 11 | bin: ./commenter 12 | - selector: 13 | os: linux 14 | arch: arm64 15 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/v0.8.9/linux_arm64_v0.8.9.tar.gz 16 | bin: ./commenter 17 | - selector: 18 | os: darwin 19 | arch: amd64 20 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/v0.8.9/darwin_amd64_v0.8.9.tar.gz 21 | bin: ./commenter 22 | - selector: 23 | os: darwin 24 | arch: arm64 25 | uri: https://github.com/aquasecurity/go-git-pr-commenter/releases/download/v0.8.9/darwin_arm64_v0.8.9.tar.gz 26 | bin: ./commenter 27 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | 6 | git checkout master 7 | git fetch --tags --all 8 | git pull 9 | 10 | LATEST_TAG=`git describe --tags --abbrev=0` 11 | read -p "The last tag was: ${LATEST_TAG}, what tag should I create? " TAG; 12 | 13 | if [[ -z $TAG ]]; then 14 | echo "you need to specify a new tag" 15 | exit 1 16 | fi 17 | 18 | git tag -a ${TAG} -m ${TAG} 19 | git push --tag 20 | 21 | 22 | BRANCH_NAME="release-${TAG}" 23 | 24 | sed -e "s/PLACEHOLDERVERSION/${TAG}/g" .github/release_template.yaml > release.yaml 25 | git checkout -b $BRANCH_NAME 26 | 27 | git add release.yaml 28 | git commit -m "Updating to latest tag ${TAG}" || true 29 | git push --set-upstream origin $BRANCH_NAME || true 30 | 31 | xdg-open "https://github.com/aquasecurity/go-git-pr-commenter/compare/${BRANCH_NAME}" 32 | --------------------------------------------------------------------------------