├── test_data ├── not_a_csv.txt ├── well-formatted.csv ├── noData_overview.csv ├── bad_date_column.csv ├── bad_first_column.csv ├── Reference_extract_output.md ├── Reference_extract_output_with_links.md ├── bad_data_value.csv ├── bad_data_negative_value.csv ├── bad_submitter_name.csv ├── deleted_user_case.csv ├── extract-commenters_reference_output.md ├── historicExtract_reference.csv ├── historicCompare_reference.csv ├── extract_reference_output.md ├── compare-commenters_reference_output.md ├── compare-submitters_reference_output.md ├── extract-commenters-history_reference_output.md ├── compare-submitters_history_reference_output.md ├── historicCompare_Integration_reference.csv └── short_overview.csv ├── CODEOWNERS ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── go.mod ├── LICENSE ├── Makefile ├── jenkins-contribution-aggregator.go ├── README.md ├── cmd ├── version.go ├── root.go ├── check_test.go ├── graphics.go ├── graphics_test.go ├── check.go ├── compare.go ├── compare_test.go ├── utilities.go ├── extract.go ├── extract_test.go └── utilities_test.go ├── docs └── documentation.md ├── .goreleaser.yml └── go.sum /test_data/not_a_csv.txt: -------------------------------------------------------------------------------- 1 | This is a blank sample file -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | * @jenkins-infra/contribution-stats 3 | -------------------------------------------------------------------------------- /test_data/well-formatted.csv: -------------------------------------------------------------------------------- 1 | first_name,last_name,username 2 | "Rob","Pike",rob 3 | Ken,Thompson,ken 4 | "Robert","Griesemer","gri" -------------------------------------------------------------------------------- /test_data/noData_overview.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | -------------------------------------------------------------------------------- /test_data/bad_date_column.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","junk","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | -------------------------------------------------------------------------------- /test_data/bad_first_column.csv: -------------------------------------------------------------------------------- 1 | submitter,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | -------------------------------------------------------------------------------- /test_data/Reference_extract_output.md: -------------------------------------------------------------------------------- 1 | # Extract 2 | 3 | | Submitter | Total_PRs | 4 | | ----------- | --------: | 5 | | basil | 1245 | 6 | | MarkEWaite | 1150 | 7 | | lemeurherve | 939 | 8 | | NotMyFault | 926 | 9 | | dduportal | 859 | 10 | | jonesbusy | 415 | 11 | | jglick | 378 | 12 | | smerle33 | 353 | 13 | | timja | 250 | 14 | | uhafner | 215 | 15 | | gounthar | 208 | 16 | | mawinter69 | 179 | 17 | | daniel-beck | 164 | 18 | -------------------------------------------------------------------------------- /test_data/Reference_extract_output_with_links.md: -------------------------------------------------------------------------------- 1 | # Extract 2 | 3 | | Submitter | Total_PRs | 4 | | ----------- | --------: | 5 | | [basil](plot/basil.png) | 1245 | 6 | | [MarkEWaite](plot/MarkEWaite.png) | 1150 | 7 | | [lemeurherve](plot/lemeurherve.png) | 939 | 8 | | [NotMyFault](plot/NotMyFault.png) | 926 | 9 | | [dduportal](plot/dduportal.png) | 859 | 10 | | [jonesbusy](plot/jonesbusy.png) | 415 | 11 | | [jglick](plot/jglick.png) | 378 | 12 | | [smerle33](plot/smerle33.png) | 353 | 13 | | [timja](plot/timja.png) | 250 | 14 | | [uhafner](plot/uhafner.png) | 215 | 15 | | [gounthar](plot/gounthar.png) | 208 | 16 | | [mawinter69](plot/mawinter69.png) | 179 | 17 | | [daniel-beck](plot/daniel-beck.png) | 164 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | #vscode settings 24 | .vscode/ 25 | 26 | #default test file 27 | top-submitter*.csv 28 | 29 | #Goreleaser output directory 30 | dist/ 31 | 32 | #exclude temporary executables or test files 33 | jenkins-contribution-aggregator 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | target-branch: master 14 | reviewers: 15 | - jenkins-infra 16 | labels: 17 | - dependencies 18 | 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "daily" 23 | open-pull-requests-limit: 10 24 | target-branch: master 25 | reviewers: 26 | - jenkins-infra 27 | labels: 28 | - dependencies -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release with goreleaser 2 | on: 3 | push: 4 | tags: 5 | - v*.*.* 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | name: goreleaser 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Unshallow Fetch 15 | run: git fetch --prune --unshallow 16 | - uses: actions/setup-go@v4 17 | with: 18 | go-version: '^1.21.1' 19 | - uses: tibdex/github-app-token@v2 20 | id: generate_homebrew_token 21 | with: 22 | app_id: ${{ secrets.GORELEASER_APP_ID }} 23 | private_key: ${{ secrets.GORELEASER_APP_PRIVKEY }} 24 | - name: Release via goreleaser 25 | uses: goreleaser/goreleaser-action@v4 26 | with: 27 | distribution: goreleaser 28 | args: release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | HOMEBREW: ${{ steps.generate_homebrew_token.outputs.token }} -------------------------------------------------------------------------------- /test_data/bad_data_value.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | "613andred",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 4 | "95-jonpet",0,0,0,0,3,0,1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 5 | "ADI10HERO",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,12,5,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 6 | "ADITYADAS1999",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,N/A,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0 7 | "APEdevelopment",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -------------------------------------------------------------------------------- /test_data/bad_data_negative_value.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | "613andred",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 4 | "95-jonpet",0,0,0,0,3,0,1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 5 | "ADI10HERO",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,12,5,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 6 | "ADITYADAS1999",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0 7 | "APEdevelopment",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,-2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -------------------------------------------------------------------------------- /test_data/bad_submitter_name.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | "613an dred",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 4 | "95-jonpet",0,0,0,0,3,0,1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 5 | "ADI10HERO",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,12,5,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 6 | "ADITYADAS1999",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0 7 | "APE development",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -------------------------------------------------------------------------------- /test_data/deleted_user_case.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | "deleted_user",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 4 | "95-jonpet",0,0,0,0,3,0,1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 5 | "ADI10HERO",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,12,5,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 6 | "ADITYADAS1999",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0 7 | "APEdevelopment",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jenkins-infra/jenkins-contribution-aggregator 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/spf13/cobra v1.7.0 7 | github.com/stretchr/testify v1.9.0 8 | ) 9 | 10 | require ( 11 | git.sr.ht/~sbinet/gg v0.5.0 // indirect 12 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect 13 | github.com/campoy/embedmd v1.0.0 // indirect 14 | github.com/go-fonts/liberation v0.3.1 // indirect 15 | github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 // indirect 16 | github.com/go-pdf/fpdf v0.8.0 // indirect 17 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 18 | golang.org/x/image v0.18.0 // indirect 19 | golang.org/x/text v0.16.0 // indirect 20 | ) 21 | 22 | require ( 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/spf13/pflag v1.0.5 // indirect 27 | gonum.org/v1/plot v0.14.0 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # PROJECT_NAME := "github-actions-demo-go" 2 | # PKG := "github.com/brpaz/$(PROJECT_NAME)" 3 | # PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/) 4 | # GO_FILES := $(shell find . -name '*.go' | grep -v /vendor/ | grep -v _test.go) 5 | 6 | ## GITHUB_ACTIONS is set when running as a Github Action 7 | 8 | .PHONY: all lint vet test full-test test-coverage build clean 9 | 10 | all: build 11 | 12 | dep: ## Get the dependencies 13 | @go mod download 14 | 15 | lint: ## Lint Golang files 16 | @golangci-lint run 17 | 18 | vet: ## Run go vet 19 | @go vet ./... 20 | 21 | test: ## Run unit tests 22 | @go test ./... 23 | 24 | test-coverage: ## Run tests with coverage 25 | @go test -short -coverprofile cover.out -covermode=atomic ./... 26 | @cat cover.out >> coverage.txt 27 | 28 | build: ## Build the binary file 29 | @goreleaser --snapshot --skip=publish --clean 30 | @cp dist/jenkins-contribution-aggregator_darwin_amd64_v1/jenkins-contribution-aggregator . 31 | 32 | clean: ## Remove previous build 33 | @rm -f ./jenkins-contribution-aggregator 34 | @rm -f ./rm top-submitters_*.csv 35 | @rm -f ./cover.out 36 | @rm -f ./coverage.txt 37 | 38 | help: ## Display this help screen 39 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -------------------------------------------------------------------------------- /jenkins-contribution-aggregator.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import "github.com/jenkins-infra/jenkins-contribution-aggregator/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | -------------------------------------------------------------------------------- /test_data/extract-commenters_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Commenters 2 | 3 | Extraction of the 35 top (non-bot) commenters 4 | over the 12 months before "2023-04". 5 | 6 | 7 | | Commenter | Total_Comments | 8 | | ------------- | -------------: | 9 | | basil | 1476 | 10 | | lemeurherve | 870 | 11 | | NotMyFault | 852 | 12 | | MarkEWaite | 788 | 13 | | dduportal | 610 | 14 | | jglick | 445 | 15 | | timja | 337 | 16 | | JLLeitschuh | 284 | 17 | | daniel-beck | 271 | 18 | | jetersen | 255 | 19 | | smerle33 | 251 | 20 | | alecharp | 139 | 21 | | kmartens27 | 138 | 22 | | jmMeessen | 134 | 23 | | janfaracik | 132 | 24 | | uhafner | 131 | 25 | | jtnord | 130 | 26 | | jonesbusy | 122 | 27 | | halkeye | 114 | 28 | | offa | 104 | 29 | | Vlatombe | 98 | 30 | | mawinter69 | 84 | 31 | | StefanSpieker | 84 | 32 | | gounthar | 69 | 33 | | zbynek | 69 | 34 | | Dohbedoh | 65 | 35 | | olamy | 56 | 36 | | krisstern | 53 | 37 | | dwnusbaum | 53 | 38 | | froque | 47 | 39 | | c00ler | 43 | 40 | | simonsymhoven | 43 | 41 | | mPokornyETM | 40 | 42 | | repolevedavaj | 40 | 43 | | DuMaM | 38 | 44 | -------------------------------------------------------------------------------- /test_data/historicExtract_reference.csv: -------------------------------------------------------------------------------- 1 | ,2020-01,2020-02,2020-03,2020-04,2020-05,2020-06,2020-07,2020-08,2020-09,2020-10,2020-11,2020-12,2021-01,2021-02,2021-03,2021-04,2021-05,2021-06,2021-07,2021-08,2021-09,2021-10,2021-11,2021-12,2022-01,2022-02,2022-03,2022-04,2022-05,2022-06,2022-07,2022-08,2022-09,2022-10,2022-11,2022-12,2023-01,2023-02,2023-03,2023-04 2 | basil,1,21,4,3,11,17,12,4,2,29,7,25,21,29,27,22,87,41,38,65,53,86,208,252,146,76,184,146,125,75,84,143,126,90,282,71,80,61,153,186 3 | MarkEWaite,41,37,39,45,27,34,27,30,33,49,47,32,42,60,56,44,27,15,35,35,50,69,86,40,50,48,78,92,38,17,34,72,50,39,166,86,137,54,36,59 4 | lemeurherve,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,39,64,53,46,34,49,32,36,61,43,51,94,87,72,68,65,135,101,57 5 | NotMyFault,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,5,14,11,33,33,61,112,99,39,23,60,78,69,95,120,85,52,61,71,99 6 | dduportal,0,0,0,0,1,0,0,0,0,0,0,10,20,10,51,39,38,54,11,37,40,48,81,27,44,39,40,51,64,40,47,52,36,65,61,33,25,39,59,89 7 | jonesbusy,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,12,3,1,0,0,21,2,5,2,7,4,9,14,30,28 8 | jglick,54,18,31,47,10,30,37,32,54,69,52,4,19,17,47,29,35,38,28,8,22,32,59,43,47,22,42,19,69,42,53,28,16,25,38,29,51,26,47,21 9 | smerle33,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,14,18,32,15,30,16,6,8,32,34,27,18,12,12,36,20 10 | timja,58,40,54,64,38,48,73,64,53,41,31,79,47,23,61,43,44,34,37,54,54,41,25,45,57,35,23,21,52,50,28,12,22,16,17,21,6,8,63,42 11 | uhafner,15,13,11,18,15,20,12,25,15,5,12,11,30,38,8,14,11,4,8,24,5,13,28,18,6,11,6,2,4,15,11,12,17,5,12,3,7,2,28,15 12 | gounthar,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,2,3,0,5,18,14,7,1,6,5,2 13 | mawinter69,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,2,5,7,11,23,12,1,0,0,6,2,0,8,14 14 | daniel-beck,7,21,36,31,26,15,25,37,30,18,16,7,14,13,39,49,27,15,9,9,10,8,39,18,12,22,33,11,15,25,64,38,29,21,19,6,14,18,17,5 15 | -------------------------------------------------------------------------------- /test_data/historicCompare_reference.csv: -------------------------------------------------------------------------------- 1 | ,2020-01,2020-02,2020-03,2020-04,2020-05,2020-06,2020-07,2020-08,2020-09,2020-10,2020-11,2020-12,2021-01,2021-02,2021-03,2021-04,2021-05,2021-06,2021-07,2021-08,2021-09,2021-10,2021-11,2021-12,2022-01,2022-02,2022-03,2022-04,2022-05,2022-06,2022-07,2022-08,2022-09,2022-10,2022-11,2022-12,2023-01,2023-02,2023-03,2023-04 2 | basil,1,21,4,3,11,17,12,4,2,29,7,25,21,29,27,22,87,41,38,65,53,86,208,252,146,76,184,146,125,75,84,143,126,90,282,71,80,61,153,186 3 | MarkEWaite,41,37,39,45,27,34,27,30,33,49,47,32,42,60,56,44,27,15,35,35,50,69,86,40,50,48,78,92,38,17,34,72,50,39,166,86,137,54,36,59 4 | lemeurherve,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,39,64,53,46,34,49,32,36,61,43,51,94,87,72,68,65,135,101,57 5 | NotMyFault,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,5,14,11,33,33,61,112,99,39,23,60,78,69,95,120,85,52,61,71,99 6 | dduportal,0,0,0,0,1,0,0,0,0,0,0,10,20,10,51,39,38,54,11,37,40,48,81,27,44,39,40,51,64,40,47,52,36,65,61,33,25,39,59,89 7 | jonesbusy,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,12,3,1,0,0,21,2,5,2,7,4,9,14,30,28 8 | jglick,54,18,31,47,10,30,37,32,54,69,52,4,19,17,47,29,35,38,28,8,22,32,59,43,47,22,42,19,69,42,53,28,16,25,38,29,51,26,47,21 9 | smerle33,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,14,18,32,15,30,16,6,8,32,34,27,18,12,12,36,20 10 | timja (churned),58,40,54,64,38,48,73,64,53,41,31,79,47,23,61,43,44,34,37,54,54,41,25,45,57,35,23,21,52,50,28,12,22,16,17,21,6,8,63,42 11 | uhafner,15,13,11,18,15,20,12,25,15,5,12,11,30,38,8,14,11,4,8,24,5,13,28,18,6,11,6,2,4,15,11,12,17,5,12,3,7,2,28,15 12 | gounthar (new),0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,2,3,0,5,18,14,7,1,6,5,2 13 | mawinter69,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,2,5,7,11,23,12,1,0,0,6,2,0,8,14 14 | daniel-beck,7,21,36,31,26,15,25,37,30,18,16,7,14,13,39,49,27,15,9,9,10,8,39,18,12,22,33,11,15,25,64,38,29,21,19,6,14,18,17,5 15 | -------------------------------------------------------------------------------- /test_data/extract_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Submitters 2 | 3 | Extraction of the 35 top submitters (non-bot PR creators) 4 | over the 12 months before "2023-04". 5 | 6 | 7 | | Submitter | Total_PRs | 8 | | ------------- | --------: | 9 | | [basil](plot/basil.png) | 1476 | 10 | | [lemeurherve](plot/lemeurherve.png) | 870 | 11 | | [NotMyFault](plot/NotMyFault.png) | 852 | 12 | | [MarkEWaite](plot/MarkEWaite.png) | 788 | 13 | | [dduportal](plot/dduportal.png) | 610 | 14 | | [jglick](plot/jglick.png) | 445 | 15 | | [timja](plot/timja.png) | 337 | 16 | | [JLLeitschuh](plot/JLLeitschuh.png) | 284 | 17 | | [daniel-beck](plot/daniel-beck.png) | 271 | 18 | | [jetersen](plot/jetersen.png) | 255 | 19 | | [smerle33](plot/smerle33.png) | 251 | 20 | | [alecharp](plot/alecharp.png) | 139 | 21 | | [kmartens27](plot/kmartens27.png) | 138 | 22 | | [jmMeessen](plot/jmMeessen.png) | 134 | 23 | | [janfaracik](plot/janfaracik.png) | 132 | 24 | | [uhafner](plot/uhafner.png) | 131 | 25 | | [jtnord](plot/jtnord.png) | 130 | 26 | | [jonesbusy](plot/jonesbusy.png) | 122 | 27 | | [halkeye](plot/halkeye.png) | 114 | 28 | | [offa](plot/offa.png) | 104 | 29 | | [Vlatombe](plot/Vlatombe.png) | 98 | 30 | | [mawinter69](plot/mawinter69.png) | 84 | 31 | | [StefanSpieker](plot/StefanSpieker.png) | 84 | 32 | | [gounthar](plot/gounthar.png) | 69 | 33 | | [zbynek](plot/zbynek.png) | 69 | 34 | | [Dohbedoh](plot/Dohbedoh.png) | 65 | 35 | | [olamy](plot/olamy.png) | 56 | 36 | | [krisstern](plot/krisstern.png) | 53 | 37 | | [dwnusbaum](plot/dwnusbaum.png) | 53 | 38 | | [froque](plot/froque.png) | 47 | 39 | | [c00ler](plot/c00ler.png) | 43 | 40 | | [simonsymhoven](plot/simonsymhoven.png) | 43 | 41 | | [mPokornyETM](plot/mPokornyETM.png) | 40 | 42 | | [repolevedavaj](plot/repolevedavaj.png) | 40 | 43 | | [DuMaM](plot/DuMaM.png) | 38 | 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | #Continuous integration action 2 | # largely inspired by https://brunopaz.dev/blog/building-a-basic-ci-cd-pipeline-for-a-golang-application-using-github-actions 3 | name: Build & Test 4 | on: 5 | push: 6 | branches: 7 | - 'main' 8 | pull_request: 9 | branches: 10 | - '*' 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '^1.20.6' 21 | 22 | - name: Check out code 23 | uses: actions/checkout@v3 24 | 25 | - name: Lint Go Code with golangci-lint 26 | uses: golangci/golangci-lint-action@v3 27 | 28 | - name: Vet Go Code 29 | run: | 30 | make vet 31 | 32 | test: 33 | name: Test 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Set up Go 37 | uses: actions/setup-go@v4 38 | with: 39 | go-version: '^1.20.6' 40 | 41 | - name: Check out code 42 | uses: actions/checkout@v3 43 | 44 | - name: Run Unit tests. 45 | run: | 46 | make test-coverage 47 | 48 | - name: Upload Coverage report to CodeCov 49 | uses: codecov/codecov-action@v4.0.1 50 | with: 51 | token: ${{secrets.CODECOV_TOKEN}} 52 | file: ./coverage.txt 53 | 54 | 55 | build: 56 | runs-on: ubuntu-latest 57 | name: Build and Integration tests 58 | needs: [lint, test] 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-go@v4 62 | with: 63 | go-version: '^1.20.6' 64 | - run: go mod download 65 | - name: Validates GO releaser config 66 | uses: goreleaser/goreleaser-action@v4 67 | with: 68 | distribution: goreleaser 69 | args: check 70 | - name: Run GoReleaser 71 | uses: goreleaser/goreleaser-action@v4 72 | with: 73 | distribution: goreleaser 74 | args: release --snapshot --skip=publish --clean 75 | 76 | -------------------------------------------------------------------------------- /test_data/compare-commenters_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Commenters (Compare) 2 | 3 | Extraction of the 35 top (non-bot) commenters 4 | over the 12 months before "2023-04". 5 | Table shows new and "churned" commenters compared 6 | to the situation 3 months before. 7 | 8 | 9 | | Commenter | Comments | status | 10 | | --------------- | -------: | ------- | 11 | | basil | 1476 | | 12 | | lemeurherve | 870 | | 13 | | NotMyFault | 852 | | 14 | | MarkEWaite | 788 | | 15 | | dduportal | 610 | | 16 | | jglick | 445 | | 17 | | timja | 337 | | 18 | | JLLeitschuh | 284 | | 19 | | daniel-beck | 271 | | 20 | | jetersen | 255 | | 21 | | smerle33 | 251 | | 22 | | alecharp | 139 | | 23 | | kmartens27 | 138 | | 24 | | jmMeessen | 134 | | 25 | | janfaracik | 132 | | 26 | | uhafner | 131 | | 27 | | jtnord | 130 | | 28 | | jonesbusy | 122 | | 29 | | halkeye | 114 | | 30 | | offa | 104 | | 31 | | Vlatombe | 98 | | 32 | | mawinter69 | 84 | | 33 | | StefanSpieker | 84 | | 34 | | gounthar | 69 | | 35 | | zbynek | 69 | | 36 | | Dohbedoh | 65 | | 37 | | olamy | 56 | | 38 | | krisstern | 53 | new | 39 | | dwnusbaum | 53 | | 40 | | froque | 47 | new | 41 | | c00ler | 43 | new | 42 | | simonsymhoven | 43 | | 43 | | mPokornyETM | 40 | new | 44 | | repolevedavaj | 40 | | 45 | | DuMaM | 38 | | 46 | | cyrille-leclerc | | churned | 47 | | kuisathaverat | | churned | 48 | | slide | | churned | 49 | | jimklimov | | churned | 50 | -------------------------------------------------------------------------------- /test_data/compare-submitters_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Submitters (Compare) 2 | 3 | Extraction of the 35 top submitters (non-bot PR creators) 4 | over the 12 months before "2023-04". 5 | Table shows new and "churned" submitters compared 6 | to the situation 3 months before. 7 | 8 | 9 | | Submitter | Total_PRs | Status | 10 | | --------------- | --------: | ------- | 11 | | basil | 1476 | | 12 | | lemeurherve | 870 | | 13 | | NotMyFault | 852 | | 14 | | MarkEWaite | 788 | | 15 | | dduportal | 610 | | 16 | | jglick | 445 | | 17 | | timja | 337 | | 18 | | JLLeitschuh | 284 | | 19 | | daniel-beck | 271 | | 20 | | jetersen | 255 | | 21 | | smerle33 | 251 | | 22 | | alecharp | 139 | | 23 | | kmartens27 | 138 | | 24 | | jmMeessen | 134 | | 25 | | janfaracik | 132 | | 26 | | uhafner | 131 | | 27 | | jtnord | 130 | | 28 | | jonesbusy | 122 | | 29 | | halkeye | 114 | | 30 | | offa | 104 | | 31 | | Vlatombe | 98 | | 32 | | mawinter69 | 84 | | 33 | | StefanSpieker | 84 | | 34 | | gounthar | 69 | | 35 | | zbynek | 69 | | 36 | | Dohbedoh | 65 | | 37 | | olamy | 56 | | 38 | | krisstern | 53 | new | 39 | | dwnusbaum | 53 | | 40 | | froque | 47 | new | 41 | | c00ler | 43 | new | 42 | | simonsymhoven | 43 | | 43 | | mPokornyETM | 40 | new | 44 | | repolevedavaj | 40 | | 45 | | DuMaM | 38 | | 46 | | cyrille-leclerc | | churned | 47 | | kuisathaverat | | churned | 48 | | slide | | churned | 49 | | jimklimov | | churned | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jenkins-contribution-aggregator 2 | 3 | 4 | ![Go Build & Test](https://github.com/jenkins-infra/jenkins-contribution-aggregator/actions/workflows/ci.yml/badge.svg) 5 | [![codecov](https://codecov.io/gh/jenkins-infra/jenkins-contribution-aggregator/graph/badge.svg?token=VVXVISDI5P)](https://codecov.io/gh/jenkins-infra/jenkins-contribution-aggregator) 6 | 7 | This tool will analyze and enrich the submitter pivot table as generated by https://github.com/jenkins-infra/jenkins-submitter-stats 8 | 9 | 10 | ## Quick Start 11 | 12 | To extract the top submitter from the pivot table, use `jenkins-contribution-aggregator extract [input file]` where `input file` is the pivot table. Although other other values can be specified, the default output file will be 13 | "top-submitters.csv". The top 35 submitters will be computed over the last 12 months. 14 | 15 | The proper format of the input pivot table can verified with the CHECK command. the format is `jenkins-contribution-aggregator check [input file]`, where `input file` is the pivot table to validate. 16 | 17 | Full documentation can be found [here](docs/documentation.md). 18 | 19 | ## Installation 20 | 21 | Download the distribution for your operating system from [/jenkins-contribution-aggregator/releases](https://github.com/jenkins-infra/jenkins-contribution-aggregator/releases). 22 | 23 | The following versions are available: 24 | 25 | * Mac OS (AMD64 and ARM64 processors) 26 | * Linux (386, AMD64, and ARM64 processors) 27 | * Windows (386 processors) 28 | 29 | ## Expected input format 30 | 31 | An example of the input data is available as [short_overview.csv](test_data/short_overview.csv) in 32 | the `test_data` directory. 33 | 34 | The first line of the csv contains the names of the different columns. The first column is the contributor's 35 | name (generally an empty column name). The following columns, containing the number of submitted pull requests 36 | for a given month, are labeled as `"MM-YYYY"`. The items are separated by a comma (`,`). There are no spaces 37 | 38 | The contributor name must be entered between quotes. The number of PR is a plain integer number. 39 | 40 | ## Installation 41 | 42 | For MacOS users, `homebrew` is the easiest installation method. 43 | 44 | - add the Homebrew tap with `brew tap jenkins-infra/tap`. 45 | - install the application with `brew install jenkins-contribution-aggregator`. 46 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | /* 4 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | */ 24 | 25 | import ( 26 | "fmt" 27 | "time" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | var ( 33 | detailed = false 34 | version = "private build" 35 | commit = "none" 36 | date = "unknown" 37 | builtBy = "" 38 | versionCmd = &cobra.Command{ 39 | Use: "version", 40 | Short: "Displays the version and build information", 41 | Long: ``, 42 | Run: func(_ *cobra.Command, _ []string) { 43 | var response string 44 | if detailed { 45 | prettyPrintedDate := "Unknown" 46 | if date != "unknown" { 47 | buildDate, error := time.Parse(time.RFC3339, date) 48 | if error == nil { 49 | prettyPrintedDate = buildDate.Format("2006-01-02 15:04") + " (UTC)" 50 | } else { 51 | prettyPrintedDate = fmt.Sprint(error) 52 | } 53 | } 54 | response = fmt.Sprintf("jenkins-contribution-aggregator :\n- version: %s\n- commit: %s\n- date: %s\n- built by: %s\n", version, commit, prettyPrintedDate, builtBy) 55 | } else { 56 | response = fmt.Sprintf("jenkins-contribution-aggregator version: %s\n", version) 57 | } 58 | 59 | fmt.Printf("%+v", response) 60 | }, 61 | } 62 | ) 63 | 64 | func init() { 65 | rootCmd.AddCommand(versionCmd) 66 | versionCmd.Flags().BoolVarP(&detailed, "detailed", "d", false, "Prints the detailed version information") 67 | } 68 | -------------------------------------------------------------------------------- /test_data/extract-commenters-history_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Commenters 2 | 3 | Extraction of the 35 top (non-bot) commenters 4 | over the 12 months before "2023-04". 5 | 6 | 7 | | Commenter | Total_Comments | 8 | | ------------- | -------------: | 9 | | [basil](commentersPlot/basil.png) | 1476 | 10 | | [lemeurherve](commentersPlot/lemeurherve.png) | 870 | 11 | | [NotMyFault](commentersPlot/NotMyFault.png) | 852 | 12 | | [MarkEWaite](commentersPlot/MarkEWaite.png) | 788 | 13 | | [dduportal](commentersPlot/dduportal.png) | 610 | 14 | | [jglick](commentersPlot/jglick.png) | 445 | 15 | | [timja](commentersPlot/timja.png) | 337 | 16 | | [JLLeitschuh](commentersPlot/JLLeitschuh.png) | 284 | 17 | | [daniel-beck](commentersPlot/daniel-beck.png) | 271 | 18 | | [jetersen](commentersPlot/jetersen.png) | 255 | 19 | | [smerle33](commentersPlot/smerle33.png) | 251 | 20 | | [alecharp](commentersPlot/alecharp.png) | 139 | 21 | | [kmartens27](commentersPlot/kmartens27.png) | 138 | 22 | | [jmMeessen](commentersPlot/jmMeessen.png) | 134 | 23 | | [janfaracik](commentersPlot/janfaracik.png) | 132 | 24 | | [uhafner](commentersPlot/uhafner.png) | 131 | 25 | | [jtnord](commentersPlot/jtnord.png) | 130 | 26 | | [jonesbusy](commentersPlot/jonesbusy.png) | 122 | 27 | | [halkeye](commentersPlot/halkeye.png) | 114 | 28 | | [offa](commentersPlot/offa.png) | 104 | 29 | | [Vlatombe](commentersPlot/Vlatombe.png) | 98 | 30 | | [mawinter69](commentersPlot/mawinter69.png) | 84 | 31 | | [StefanSpieker](commentersPlot/StefanSpieker.png) | 84 | 32 | | [gounthar](commentersPlot/gounthar.png) | 69 | 33 | | [zbynek](commentersPlot/zbynek.png) | 69 | 34 | | [Dohbedoh](commentersPlot/Dohbedoh.png) | 65 | 35 | | [olamy](commentersPlot/olamy.png) | 56 | 36 | | [krisstern](commentersPlot/krisstern.png) | 53 | 37 | | [dwnusbaum](commentersPlot/dwnusbaum.png) | 53 | 38 | | [froque](commentersPlot/froque.png) | 47 | 39 | | [c00ler](commentersPlot/c00ler.png) | 43 | 40 | | [simonsymhoven](commentersPlot/simonsymhoven.png) | 43 | 41 | | [mPokornyETM](commentersPlot/mPokornyETM.png) | 40 | 42 | | [repolevedavaj](commentersPlot/repolevedavaj.png) | 40 | 43 | | [DuMaM](commentersPlot/DuMaM.png) | 38 | 44 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // rootCmd represents the base command when called without any subcommands 31 | var rootCmd = &cobra.Command{ 32 | Use: "jenkins-contribution-aggregator", 33 | Short: "Tool to enrich Jenkins Submitter pivot tables", 34 | Long: `This tool checks and extracts data from the Jenkins Submitter pivot tables. 35 | These files, generated by the Jenkins Submitters Stats scripts, show the 36 | number of GitHub PRs in the jenkinsci and jenkins-infra org per month for each 37 | of the submitters. 38 | 39 | The CHECK command can be used to validate that the file is of the expected format. 40 | The EXTRACT command will list the 35 most active submitters for the given period.`, 41 | // Uncomment the following line if your bare application 42 | // has an action associated with it: 43 | // Run: func(cmd *cobra.Command, args []string) { }, 44 | } 45 | 46 | // Execute adds all child commands to the root command and sets flags appropriately. 47 | // This is called by main.main(). It only needs to happen once to the rootCmd. 48 | func Execute() { 49 | err := rootCmd.Execute() 50 | if err != nil { 51 | os.Exit(1) 52 | } 53 | } 54 | 55 | func init() { 56 | // Here you will define your flags and configuration settings. 57 | 58 | //Disable the Cobra completion options 59 | rootCmd.CompletionOptions.DisableDefaultCmd = true 60 | 61 | // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jenkins-contribution-aggregator.yaml)") 62 | 63 | } 64 | -------------------------------------------------------------------------------- /test_data/compare-submitters_history_reference_output.md: -------------------------------------------------------------------------------- 1 | # Top Submitters (Compare) 2 | 3 | Extraction of the 35 top submitters (non-bot PR creators) 4 | over the 12 months before "2023-04". 5 | Table shows new and "churned" submitters compared 6 | to the situation 3 months before. 7 | 8 | 9 | | Submitter | Total_PRs | Status | 10 | | --------------- | --------: | ------- | 11 | | [basil](plot/basil.png) | 1476 | | 12 | | [lemeurherve](plot/lemeurherve.png) | 870 | | 13 | | [NotMyFault](plot/NotMyFault.png) | 852 | | 14 | | [MarkEWaite](plot/MarkEWaite.png) | 788 | | 15 | | [dduportal](plot/dduportal.png) | 610 | | 16 | | [jglick](plot/jglick.png) | 445 | | 17 | | [timja](plot/timja.png) | 337 | | 18 | | [JLLeitschuh](plot/JLLeitschuh.png) | 284 | | 19 | | [daniel-beck](plot/daniel-beck.png) | 271 | | 20 | | [jetersen](plot/jetersen.png) | 255 | | 21 | | [smerle33](plot/smerle33.png) | 251 | | 22 | | [alecharp](plot/alecharp.png) | 139 | | 23 | | [kmartens27](plot/kmartens27.png) | 138 | | 24 | | [jmMeessen](plot/jmMeessen.png) | 134 | | 25 | | [janfaracik](plot/janfaracik.png) | 132 | | 26 | | [uhafner](plot/uhafner.png) | 131 | | 27 | | [jtnord](plot/jtnord.png) | 130 | | 28 | | [jonesbusy](plot/jonesbusy.png) | 122 | | 29 | | [halkeye](plot/halkeye.png) | 114 | | 30 | | [offa](plot/offa.png) | 104 | | 31 | | [Vlatombe](plot/Vlatombe.png) | 98 | | 32 | | [mawinter69](plot/mawinter69.png) | 84 | | 33 | | [StefanSpieker](plot/StefanSpieker.png) | 84 | | 34 | | [gounthar](plot/gounthar.png) | 69 | | 35 | | [zbynek](plot/zbynek.png) | 69 | | 36 | | [Dohbedoh](plot/Dohbedoh.png) | 65 | | 37 | | [olamy](plot/olamy.png) | 56 | | 38 | | [krisstern](plot/krisstern.png) | 53 | new | 39 | | [dwnusbaum](plot/dwnusbaum.png) | 53 | | 40 | | [froque](plot/froque.png) | 47 | new | 41 | | [c00ler](plot/c00ler.png) | 43 | new | 42 | | [simonsymhoven](plot/simonsymhoven.png) | 43 | | 43 | | [mPokornyETM](plot/mPokornyETM.png) | 40 | new | 44 | | [repolevedavaj](plot/repolevedavaj.png) | 40 | | 45 | | [DuMaM](plot/DuMaM.png) | 38 | | 46 | | [cyrille-leclerc](plot/cyrille-leclerc.png) | | churned | 47 | | [kuisathaverat](plot/kuisathaverat.png) | | churned | 48 | | [slide](plot/slide.png) | | churned | 49 | | [jimklimov](plot/jimklimov.png) | | churned | 50 | -------------------------------------------------------------------------------- /cmd/check_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import "testing" 25 | 26 | func Test_checkFile(t *testing.T) { 27 | type args struct { 28 | fileName string 29 | isSilent bool 30 | } 31 | tests := []struct { 32 | name string 33 | args args 34 | want bool 35 | }{ 36 | { 37 | "Bad first column", 38 | args{ 39 | fileName: "../test_data/bad_first_column.csv", 40 | isSilent: false, 41 | }, 42 | false, 43 | }, 44 | { 45 | "Bad date column", 46 | args{ 47 | fileName: "../test_data/bad_date_column.csv", 48 | isSilent: false, 49 | }, 50 | false, 51 | }, 52 | { 53 | "Bad submitter name", 54 | args{ 55 | fileName: "../test_data/bad_submitter_name.csv", 56 | isSilent: false, 57 | }, 58 | false, 59 | }, 60 | { 61 | "deleted user case", 62 | args{ 63 | fileName: "../test_data/deleted_user_case.csv", 64 | isSilent: false, 65 | }, 66 | true, 67 | }, 68 | { 69 | "non integer data value", 70 | args{ 71 | fileName: "../test_data/bad_data_value.csv", 72 | isSilent: false, 73 | }, 74 | false, 75 | }, 76 | { 77 | "negative data value", 78 | args{ 79 | fileName: "../test_data/bad_data_negative_value.csv", 80 | isSilent: false, 81 | }, 82 | false, 83 | }, 84 | { 85 | "file not found", 86 | args{ 87 | fileName: "../test_data/blaah.csv", 88 | isSilent: false, 89 | }, 90 | false, 91 | }, 92 | { 93 | "Happy case", 94 | args{ 95 | fileName: "../test_data/overview.csv", 96 | isSilent: false, 97 | }, 98 | true, 99 | }, 100 | } 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | if got := checkFile(tt.args.fileName, tt.args.isSilent); got != tt.want { 104 | t.Errorf("checkFile() = %v, want %v", got, tt.want) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/documentation.md: -------------------------------------------------------------------------------- 1 | # jenkins-contribution-aggregator documentation 2 | 3 | This tool checks and extracts data from the Jenkins Submitter pivot tables. 4 | These files, generated by the Jenkins Submitters Stats scripts, show the 5 | number of GitHub PRs in the jenkinsci and jenkins-infra org per month for each 6 | of the submitters. 7 | 8 | The CHECK command can be used to validate that the file is of the expected format. 9 | The EXTRACT command will list the 35 most active submitters for the given period. 10 | 11 | Usage: 12 | `jenkins-contribution-aggregator [command]` 13 | 14 | Available Commands: 15 | * [check](#CHECK) - Validates if input file has the correct format 16 | * [extract](#EXTRACT) - Extracts the top submitters from the supplied pivot table 17 | * [version](#VERSION) - Displays the version and build information 18 | * help - Help about any command 19 | 20 | --- 21 | **CHECK** 22 | 23 | The CHECK command validates whether the input file is processable. 24 | It must absolutely be generated by the GNU "datamash" pivot function in 25 | order to be successfully processed. 26 | 27 | Usage: 28 | `jenkins-contribution-aggregator check [input file] [flags]` 29 | 30 | Flags: 31 | ``` 32 | -v, --verbose Displays useful info during the validation 33 | -h, --help help for check 34 | ``` 35 | 36 | --- 37 | **EXTRACT** 38 | 39 | This command extract the top submitter for a given period (by default 12 months). 40 | This interval is counted by default from the last month available in the pivot table. 41 | The input file is first validated before being processed. 42 | 43 | If not specified, the output file name is hardcoded to "top-submitters_YYYY-MM.csv". 44 | The "YYYY-MM" stands for the specified end month (see "--month" flag). It is "LATEST" 45 | if not end month was specified (default). 46 | 47 | The "months" parameter is the number of months used to compute the top users, 48 | counting from backwards from the last month. If a 0 months is specified, all the 49 | available is counted. 50 | 51 | The "topSize" parameter defines the number of users considered as top users. 52 | If more submitters with the same amount of total PRs exist ("ex aequo"), they are included in 53 | the list (resulting in more thant the specified number of top users). 54 | 55 | Usage: 56 | `jenkins-contribution-aggregator extract [input file] [flags]` 57 | 58 | Flags: 59 | ``` 60 | -h, --help help for extract 61 | -m, --month string Month to extract top submitters. (default "latest") 62 | -o, --out string Output file name. (default "top-submitters_YYYY-MM.csv") 63 | -p, --period int Number of months to accumulate. (default 12) 64 | -t, --topSize int Number of top submitters to extract. (default 35) 65 | -v, --verbose Displays useful info during the extraction 66 | ``` 67 | 68 | --- 69 | **VERSION** 70 | 71 | Displays the version and build information 72 | 73 | Usage: 74 | `jenkins-contribution-aggregator version [flags]`` 75 | 76 | Flags: 77 | ``` 78 | -d, --detailed Prints the detailed version information 79 | -h, --help help for version 80 | ``` 81 | -------------------------------------------------------------------------------- /test_data/historicCompare_Integration_reference.csv: -------------------------------------------------------------------------------- 1 | ,2020-01,2020-02,2020-03,2020-04,2020-05,2020-06,2020-07,2020-08,2020-09,2020-10,2020-11,2020-12,2021-01,2021-02,2021-03,2021-04,2021-05,2021-06,2021-07,2021-08,2021-09,2021-10,2021-11,2021-12,2022-01,2022-02,2022-03,2022-04,2022-05,2022-06,2022-07,2022-08,2022-09,2022-10,2022-11,2022-12,2023-01,2023-02,2023-03,2023-04 2 | basil,1,21,4,3,11,17,12,4,2,29,7,25,21,29,27,22,87,41,38,65,53,86,208,252,146,76,184,146,125,75,84,143,126,90,282,71,80,61,153,186 3 | lemeurherve,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,39,64,53,46,34,49,32,36,61,43,51,94,87,72,68,65,135,101,57 4 | NotMyFault,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,5,14,11,33,33,61,112,99,39,23,60,78,69,95,120,85,52,61,71,99 5 | MarkEWaite,41,37,39,45,27,34,27,30,33,49,47,32,42,60,56,44,27,15,35,35,50,69,86,40,50,48,78,92,38,17,34,72,50,39,166,86,137,54,36,59 6 | dduportal,0,0,0,0,1,0,0,0,0,0,0,10,20,10,51,39,38,54,11,37,40,48,81,27,44,39,40,51,64,40,47,52,36,65,61,33,25,39,59,89 7 | jglick,54,18,31,47,10,30,37,32,54,69,52,4,19,17,47,29,35,38,28,8,22,32,59,43,47,22,42,19,69,42,53,28,16,25,38,29,51,26,47,21 8 | timja,58,40,54,64,38,48,73,64,53,41,31,79,47,23,61,43,44,34,37,54,54,41,25,45,57,35,23,21,52,50,28,12,22,16,17,21,6,8,63,42 9 | JLLeitschuh,1,185,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,226,10,1,34,13,0,0,0,0,0 10 | daniel-beck,7,21,36,31,26,15,25,37,30,18,16,7,14,13,39,49,27,15,9,9,10,8,39,18,12,22,33,11,15,25,64,38,29,21,19,6,14,18,17,5 11 | jetersen,45,2,5,11,12,5,6,2,0,4,0,2,1,1,1,0,1,0,1,1,0,1,7,7,2,1,0,15,3,170,76,3,0,0,0,0,0,0,0,3 12 | smerle33,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,14,18,32,15,30,16,6,8,32,34,27,18,12,12,36,20 13 | alecharp,0,0,2,1,0,2,3,0,1,0,0,3,5,10,13,4,2,1,0,0,0,0,5,1,1,5,1,4,9,5,7,3,4,32,20,6,14,5,12,22 14 | kmartens27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,24,14,6,11,22,15,13,3,5,1,11,13 15 | jmMeessen,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,19,1,15,10,7,1,2,4,41,7,30,8,9,12,9,4,8,0 16 | janfaracik,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,2,4,8,4,4,8,9,18,24,9,20,7,12,13,9,2,3,5,18,10 17 | uhafner,15,13,11,18,15,20,12,25,15,5,12,11,30,38,8,14,11,4,8,24,5,13,28,18,6,11,6,2,4,15,11,12,17,5,12,3,7,2,28,15 18 | jtnord,5,2,3,3,1,3,2,0,1,6,3,7,0,3,9,14,4,10,4,10,9,10,9,0,10,9,5,4,8,11,8,18,4,6,8,5,5,9,27,21 19 | jonesbusy,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,12,3,1,0,0,21,2,5,2,7,4,9,14,30,28 20 | halkeye,24,14,7,8,8,12,7,32,25,2,7,1,2,13,13,13,5,6,7,2,12,20,25,55,19,2,9,18,0,3,9,2,7,29,26,13,10,1,7,7 21 | offa,1,3,0,0,0,0,0,0,0,0,0,0,0,0,0,1,6,6,7,12,8,12,7,10,17,17,7,7,8,7,24,16,20,0,8,17,4,0,0,0 22 | Vlatombe,9,12,11,16,14,15,4,8,12,14,21,7,7,11,1,5,7,14,4,4,18,28,34,11,5,8,15,5,16,5,1,10,7,7,6,2,26,8,8,2 23 | mawinter69,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,2,5,7,11,23,12,1,0,0,6,2,0,8,14 24 | StefanSpieker,7,3,1,12,3,3,9,6,1,9,8,7,4,6,0,4,8,3,1,4,9,22,2,5,1,9,2,1,3,3,0,3,6,8,21,23,3,6,5,3 25 | gounthar,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,2,3,0,5,18,14,7,1,6,5,2 26 | zbynek,5,16,4,20,6,7,1,2,5,8,8,5,7,5,0,1,21,7,14,9,6,3,5,2,4,4,8,17,2,1,0,11,10,10,10,1,5,6,12,1 27 | Dohbedoh,4,4,0,0,1,3,2,2,2,5,3,4,2,0,2,3,1,2,6,2,1,4,8,1,0,1,5,0,4,15,9,6,2,4,5,5,1,5,7,2 28 | olamy,1,5,5,6,5,10,2,1,3,11,4,4,4,11,13,10,5,11,4,3,2,6,5,2,2,18,4,2,5,2,3,9,4,2,5,1,3,8,10,4 29 | krisstern (new),0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,2,1,1,2,0,0,5,5,5,2,4,5,12,6,7 30 | dwnusbaum,13,2,17,10,17,6,15,14,14,8,11,0,2,8,1,1,1,0,5,1,1,0,1,5,2,3,3,3,11,8,4,0,3,7,11,3,3,1,2,0 31 | froque (new),0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,1,0,0,0,0,0,0,1,0,0,1,0,0,6,21,10,8,0 32 | c00ler (new),0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,7,6,3,4,5,4,6 33 | simonsymhoven,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,69,12,9,0,0,0,0,0,0,0,0,0,0,4,12,2,23,2,0,0,0,0,0,0 34 | mPokornyETM (new),0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,1,1,0,0,10,12,1,7,3,3,2 35 | repolevedavaj,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,6,0,0,0,0,1,14,0,0,0,4,8,4,8,1,0,0 36 | DuMaM,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,23,2,3,5,3,0,12,6,1,1,1,1,3 37 | cyrille-leclerc (churned),0,0,1,0,0,0,0,0,0,0,0,0,0,0,6,0,10,3,1,2,2,3,9,19,11,12,13,5,4,6,2,2,3,2,0,1,0,2,3,6 38 | kuisathaverat (churned),5,0,1,3,0,3,1,1,3,11,3,8,2,2,5,0,7,0,1,4,0,0,2,0,5,0,3,9,0,2,6,6,2,9,0,2,0,1,0,0 39 | slide (churned),5,6,17,25,5,7,27,9,10,10,8,6,3,2,2,4,4,1,4,1,1,7,3,1,0,3,1,4,4,0,3,1,4,9,7,0,0,0,2,0 40 | jimklimov (churned),0,3,2,2,0,1,0,0,0,0,0,14,0,1,3,5,2,1,0,0,10,0,0,1,0,1,2,2,0,0,1,2,1,2,22,0,2,1,0,0 41 | -------------------------------------------------------------------------------- /cmd/graphics.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "path" 27 | "strconv" 28 | "strings" 29 | 30 | "gonum.org/v1/plot" 31 | "gonum.org/v1/plot/plotter" 32 | "gonum.org/v1/plot/plotutil" 33 | "gonum.org/v1/plot/vg" 34 | ) 35 | 36 | func plotAllHistoryFiles(plotDirectory string, historicDataSlice [][]string, dataType InputType) error { 37 | 38 | var header []string 39 | 40 | for row, historyRow := range historicDataSlice { 41 | if row == 0 { 42 | header = historyRow[1:] 43 | } else { 44 | err := plot_bargraph(plotDirectory, historyRow[0], dataType, header, historyRow[1:]) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | } 50 | 51 | return nil 52 | } 53 | 54 | // TODO: add type for legends 55 | // TODO: how is the data passed so that it can be formatted 56 | // TODO: add parameter to limit the size of the data displayed 57 | // Plots the passed data in a png file named after the user in the specified directory 58 | func plot_bargraph(plotDirectory string, name string, dataType InputType, xLabels []string, values []string) error { 59 | 60 | p := plot.New() 61 | 62 | //In case of a compare, the name is appended with "new" or "churned". So we need to clean it up 63 | name_element := strings.Split(name, " ") 64 | cleanedName := name_element[0] 65 | 66 | plotFileName := path.Join(plotDirectory, cleanedName+".png") 67 | 68 | if dataType == InputTypeCommenters { 69 | p.Title.Text = "Comments by " + cleanedName 70 | } else { 71 | p.Title.Text = "Submissions by " + cleanedName 72 | } 73 | p.Y.Label.Text = "Count" 74 | 75 | w := vg.Points(20) 76 | 77 | floatValues, err := convertValuesToInts(values) 78 | if err != nil { 79 | return err 80 | } 81 | groupA := plotter.Values(floatValues) 82 | 83 | barsA, err := plotter.NewBarChart(groupA, w) 84 | if err != nil { 85 | return err 86 | } 87 | barsA.LineStyle.Width = vg.Length(0) 88 | barsA.Color = plotutil.Color(0) 89 | 90 | p.Add(barsA) 91 | simplifiedLabels := simplifyAxisLabels(xLabels) 92 | 93 | p.NominalX(simplifiedLabels...) 94 | 95 | return p.Save(10*vg.Inch, 6*vg.Inch, plotFileName) 96 | } 97 | 98 | // take the list of months and transforms this to a lighter list that can be displayed on the graph 99 | func simplifyAxisLabels(inputLabels []string) []string { 100 | var outputLabels []string 101 | currentYear := "" 102 | 103 | for _, oldLabel := range inputLabels { 104 | //Labels come in the form YYYY-MM 105 | splittedLabel := strings.Split(oldLabel, "-") 106 | labelsYear := splittedLabel[0] 107 | if labelsYear != currentYear { 108 | outputLabels = append(outputLabels, labelsYear) 109 | currentYear = labelsYear 110 | } else { 111 | outputLabels = append(outputLabels, "") 112 | } 113 | } 114 | 115 | return outputLabels 116 | } 117 | 118 | // Converts a slice of numerical strings into a slice of floats. 119 | func convertValuesToInts(stringValues []string) ([]float64, error) { 120 | var floatValues []float64 121 | 122 | for _, stringValue := range stringValues { 123 | value, err := strconv.ParseFloat(strings.TrimSpace(stringValue), 64) 124 | if err != nil { 125 | return nil, fmt.Errorf("Unexpected error converting data to int (%v)", err) 126 | } else { 127 | floatValues = append(floatValues, value) 128 | } 129 | } 130 | return floatValues, nil 131 | } 132 | -------------------------------------------------------------------------------- /cmd/graphics_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "path/filepath" 27 | "reflect" 28 | "testing" 29 | 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | func Test_plot_bargraph(t *testing.T) { 34 | //setup environment 35 | tempDir := t.TempDir() 36 | 37 | labels := []string{"2020-01", "2020-02", "2020-03", "2020-04", "2020-05", "2020-06", "2020-07", "2020-08", "2020-09", "2020-10", "2020-11", "2020-12", 38 | "2021-01", "2021-02", "2021-03", "2021-04", "2021-05", "2021-06", "2021-07", "2021-08", "2021-09", "2021-10", "2021-11", "2021-12", 39 | "2022-01", "2022-02", "2022-03", "2022-04", "2022-05", "2022-06", "2022-07", "2022-08", "2022-09", "2022-10", "2022-11", "2022-12", 40 | "2023-01", "2023-02", "2023-03", "2023-04", "2023-05", "2023-06", "2023-07", "2023-08", "2023-09", "2023-10", "2023-11", "2023-12", 41 | "2024-01", "2024-02", "2024-03", "2024-04"} 42 | 43 | values := []string{"43", "39", "42", "48", "31", "36", "31", "32", "38", "53", "51", "35", 44 | "43", "64", "58", "45", "28", "17", "37", "38", "54", "76", "89", "43", 45 | "52", "48", "104", "99", "43", "19", "34", "76", "61", "39", "172", "91", 46 | "147", "54", "43", "65", "59", "126", "136", "171", "85", "113", "81", "143", 47 | "76", "22", "44", "31"} 48 | 49 | err := plot_bargraph(tempDir, "test", InputTypeSubmitters, labels, values) 50 | assert.NoError(t, err, "Function should not have failed") 51 | outputPngFileName := filepath.Join(tempDir, "test.png") 52 | assert.FileExists(t, outputPngFileName, "No graphic file generated") 53 | fmt.Printf("To view the file generated: open %s\n", outputPngFileName) 54 | } 55 | 56 | func Test_generateAxisLabels(t *testing.T) { 57 | type args struct { 58 | inputLabels []string 59 | } 60 | tests := []struct { 61 | name string 62 | args args 63 | want []string 64 | }{ 65 | { 66 | "Simple case", 67 | args{inputLabels: []string{"2020-01", "2020-02", "2020-03", "2020-04", "2020-05", "2020-06", "2020-07", "2020-08", "2020-09", "2020-10", "2020-11", "2020-12", "2021-01", "2021-02"}}, 68 | []string{"2020", "", "", "", "", "", "", "", "", "", "", "", "2021", ""}, 69 | }, 70 | } 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | if got := simplifyAxisLabels(tt.args.inputLabels); !reflect.DeepEqual(got, tt.want) { 74 | t.Errorf("generateAxisLabels() = %v, want %v", got, tt.want) 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func Test_convertValuesToFloats(t *testing.T) { 81 | type args struct { 82 | stringValues []string 83 | } 84 | tests := []struct { 85 | name string 86 | args args 87 | want []float64 88 | wantErr bool 89 | }{ 90 | { 91 | "Happy case", 92 | args{stringValues: []string{"1", "2", "3"}}, 93 | []float64{1, 2, 3}, 94 | false, 95 | }, 96 | //TODO: empty input 97 | { 98 | "empty element", 99 | args{stringValues: []string{"1", "2", ""}}, 100 | nil, 101 | true, 102 | }, 103 | { 104 | "space element", 105 | args{stringValues: []string{"1", "2", " "}}, 106 | nil, 107 | true, 108 | }, 109 | { 110 | "invalid value", 111 | args{stringValues: []string{"1", "2", "junk"}}, 112 | nil, 113 | true, 114 | }, 115 | } 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | got, err := convertValuesToInts(tt.args.stringValues) 119 | if (err != nil) != tt.wantErr { 120 | t.Errorf("convertValuesToInts() error = %v, wantErr %v", err, tt.wantErr) 121 | return 122 | } 123 | if !reflect.DeepEqual(got, tt.want) { 124 | t.Errorf("convertValuesToInts() = %v, want %v", got, tt.want) 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: jenkins-contribution-aggregator 3 | 4 | goos: 5 | - linux 6 | - windows 7 | - darwin 8 | goarch: 9 | - '386' 10 | - amd64 11 | - arm 12 | - arm64 13 | goarm: 14 | - '6' 15 | 16 | ignore: 17 | - goos: darwin 18 | goarch: '386' 19 | - goos: windows 20 | goarch: amd64 21 | - goos: windows 22 | goarch: arm64 23 | - goos: windows 24 | goarch: arm 25 | 26 | ldflags: 27 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-aggregator/cmd.version={{.Version}} 28 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-aggregator/cmd.commit={{.Commit}} 29 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-aggregator/cmd.date={{.Date}} 30 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-aggregator/cmd.builtBy=goReleaser 31 | 32 | # See Goreleaser documentation at https://goreleaser.com/customization/homebrew/ for 33 | # more details. 34 | brews: 35 | - 36 | # Name template of the recipe 37 | # Default to project name 38 | name: jenkins-contribution-aggregator 39 | 40 | # # IDs of the archives to use. 41 | # # Defaults to all. 42 | # ids: 43 | # - foo 44 | # - bar 45 | 46 | # GOARM to specify which 32-bit arm version to use if there are multiple versions 47 | # from the build section. Brew formulas support atm only one 32-bit version. 48 | # Default is 6 for all artifacts or each id if there a multiple versions. 49 | goarm: '6' 50 | 51 | # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the 52 | # same kind. We will probably unify this in the next major version like it is done with scoop. 53 | 54 | # GitHub/GitLab repository to push the formula to 55 | repository: 56 | owner: jenkins-infra 57 | name: homebrew-tap 58 | # Optionally a branch can be provided. If the branch does not exist, it 59 | # will be created. If no branch is listed, the default branch will be used 60 | branch: main 61 | 62 | # # Optionally a token can be provided, if it differs from the token provided to GoReleaser 63 | token: "{{ .Env.HOMEBREW }}" 64 | 65 | # Template for the url which is determined by the given Token (github or gitlab) 66 | # Default for github is "https://github.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" 67 | # Default for gitlab is "https://gitlab.com///-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}" 68 | # Default for gitea is "https://gitea.com///releases/download/{{ .Tag }}/{{ .ArtifactName }}" 69 | url_template: "https://github.com/jenkins-infra/jenkins-contribution-aggregator/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 70 | 71 | # Allows you to set a custom download strategy. Note that you'll need 72 | # to implement the strategy and add it to your tap repository. 73 | # Example: https://docs.brew.sh/Formula-Cookbook#specifying-the-download-strategy-explicitly 74 | # Default is empty. 75 | download_strategy: CurlDownloadStrategy 76 | 77 | # # Allows you to add a custom require_relative at the top of the formula template 78 | # # Default is empty 79 | # custom_require: custom_download_strategy 80 | 81 | # Git author used to commit to the repository. 82 | # Defaults are shown. 83 | commit_author: 84 | name: goreleaserbot 85 | email: jean-marc@meessen-web.org 86 | 87 | # The project name and current git tag are used in the format string. 88 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 89 | 90 | # Folder inside the repository to put the formula. 91 | # Default is the root folder. 92 | directory: Formula 93 | 94 | # # Caveats for the user of your binary. 95 | # # Default is empty. 96 | # caveats: "How to use this binary" 97 | 98 | # # Your app's homepage. 99 | # # Default is empty. 100 | homepage: "https://github.com/jenkins-infra/jenkins-contribution-aggregator" 101 | 102 | # Template of your app's description. 103 | # Default is empty. 104 | description: "Jenkins Submitter Pivot Table analyzer." 105 | 106 | # SPDX identifier of your app's license. 107 | # Default is empty. 108 | license: "MIT" 109 | 110 | # Setting this will prevent goreleaser to actually try to commit the updated 111 | # formula - instead, the formula file will be stored on the dist folder only, 112 | # leaving the responsibility of publishing it to the user. 113 | # If set to auto, the release will not be uploaded to the homebrew tap 114 | # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 115 | # Default is false. 116 | # skip_upload: auto 117 | 118 | 119 | # So you can `brew test` your formula. 120 | # Default is empty. 121 | test: | 122 | system "#{bin}/jenkins-contribution-aggregator version -d" 123 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "encoding/csv" 26 | "fmt" 27 | "log" 28 | "os" 29 | "regexp" 30 | "strconv" 31 | 32 | "github.com/spf13/cobra" 33 | ) 34 | 35 | var isVerboseCheck bool 36 | 37 | // checkCmd represents the check command 38 | var checkCmd = &cobra.Command{ 39 | Use: "check [input file]", 40 | Short: "Validates if input file has the correct format", 41 | Long: `The CHECK command validates whether the input file is processable. 42 | It must absolutely be generated by the GNU "datamash" pivot function in 43 | order to be successfully processed.`, 44 | Args: func(cmd *cobra.Command, args []string) error { 45 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 46 | return err 47 | } 48 | if !isFileValid(args[0]) { 49 | return fmt.Errorf("Invalid file") 50 | } 51 | return nil 52 | }, 53 | Run: func(cmd *cobra.Command, args []string) { 54 | 55 | // When called standalone, we want to give at least some information 56 | isSilent := false 57 | if !checkFile(args[0], isSilent) { 58 | fmt.Print("Check failed.") 59 | os.Exit(1) 60 | } 61 | }, 62 | } 63 | 64 | // initialize the Cobra processor and flags 65 | func init() { 66 | checkCmd.PersistentFlags().BoolVarP(&isVerboseCheck, "verbose", "v", false, "Displays useful info during the validation") 67 | 68 | rootCmd.AddCommand(checkCmd) 69 | } 70 | 71 | // Loads the data from a file and try to parse it as a CSV 72 | func checkFile(fileName string, isSilent bool) bool { 73 | 74 | var isValidTable = true 75 | if isSilent { 76 | isVerboseCheck = false 77 | } 78 | 79 | f, err := os.Open(fileName) 80 | if err != nil { 81 | log.Printf("Unable to read input file "+fileName+"\n", err) 82 | return false 83 | } 84 | defer f.Close() 85 | 86 | r := csv.NewReader(f) 87 | 88 | //The first record is not properly formatted, we skip it 89 | firstLine, err1 := r.Read() 90 | if err1 != nil { 91 | log.Printf("Unexpected error loading"+fileName+"\n", err) 92 | return false 93 | } 94 | 95 | if isVerboseCheck { 96 | fmt.Println("Checking file format") 97 | fmt.Printf(" - Number of columns defined in header: %d\n", len(firstLine)) 98 | } 99 | 100 | // first column should be empty 101 | if firstLine[0] != "" { 102 | fmt.Println("Not the expected first column name (should be empty)") 103 | return false 104 | } 105 | if isVerboseCheck { 106 | fmt.Println(" - File's header start with empty column name.") 107 | } 108 | 109 | //loop through columns to check headings 110 | month_regexp, _ := regexp.Compile("20[0-9]{2}-[0-9]{2}") 111 | for i, s := range firstLine { 112 | if i != 0 { 113 | if !month_regexp.MatchString(s) { 114 | fmt.Printf("Column header %s is not of the expected format (YYYY-MM)\n", s) 115 | return false 116 | } 117 | } 118 | } 119 | if isVerboseCheck { 120 | endMonth := firstLine[len(firstLine)-1] 121 | fmt.Printf(" - File's header data column format (\"20YY-MM\"). Most recent data is \"%s\"\n", endMonth) 122 | } 123 | 124 | nbrOfColumns := len(firstLine) 125 | if nbrOfColumns < 3 { 126 | fmt.Printf("Not enough monthly data available\n") 127 | return false 128 | } 129 | if isVerboseCheck { 130 | fmt.Printf(" - More than one month data available\n") 131 | } 132 | 133 | records, err := r.ReadAll() 134 | if err != nil { 135 | log.Printf("Unexpected error loading"+fileName+"\n", err) 136 | return false 137 | } 138 | 139 | if len(records) < 2 { 140 | fmt.Printf("No data available after the header\n") 141 | return false 142 | } 143 | if isVerboseCheck { 144 | fmt.Println(" - At least one submitter's data available") 145 | } 146 | 147 | //The GitHub user validation regexp (see https://stackoverflow.com/questions/58726546/github-username-convention-using-regex) 148 | // should be regexp.Compile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`). But the dataset contains "invalid" data: username ending with a "-" or 149 | // a double "-" in the name. 150 | name_exp, _ := regexp.Compile(`^[a-zA-Z0-9\-]+$`) 151 | 152 | //Check the loaded data 153 | for i, dataLine := range records { 154 | //Skip header line as it has already been checked 155 | if i == 0 { 156 | continue 157 | } 158 | for ii, column := range dataLine { 159 | //check the GitHub user (first columns) 160 | if ii == 0 { 161 | if !(len(column) < 40 && len(column) > 0 && name_exp.MatchString(column)) { 162 | if column != "deleted_user" { 163 | fmt.Printf("User \"%s\" at line %d does not follow GitHub rules\n", column, i) 164 | return false 165 | } 166 | } 167 | } else { 168 | // check the other columns is an integer (we don't check the sign) 169 | if data_value, err := strconv.Atoi(column); err != nil { 170 | fmt.Printf("Value \"%s\" at line %d (column %d) isn't an integer\n", column, i, ii) 171 | return false 172 | } else { 173 | if data_value < 0 { 174 | fmt.Printf("Value \"%s\" at line %d (column %d) is negative\n", column, i, ii) 175 | return false 176 | } 177 | } 178 | } 179 | } 180 | } 181 | 182 | if isVerboseCheck { 183 | fmt.Println(" - Number of data columns match header columns.") 184 | fmt.Printf(" - Records have a valid GitHub username and number of submitted PRs. (%d data records)\n", len(records)-1) 185 | } 186 | 187 | if !isSilent { 188 | fmt.Printf("\nSuccessfully checked \"%s\"\n It is a valid Jenkins Submitter Pivot Table and can be processes\n\n", fileName) 189 | } 190 | 191 | return isValidTable 192 | } 193 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo= 2 | git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8= 3 | git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= 6 | github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= 7 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= 8 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= 9 | github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= 10 | github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= 15 | github.com/go-fonts/latin-modern v0.3.1 h1:/cT8A7uavYKvglYXvrdDw4oS5ZLkcOU22fa2HJ1/JVM= 16 | github.com/go-fonts/liberation v0.3.1 h1:9RPT2NhUpxQ7ukUvz3jeUckmN42T9D9TpjtQcqK/ceM= 17 | github.com/go-fonts/liberation v0.3.1/go.mod h1:jdJ+cqF+F4SUL2V+qxBth8fvBpBDS7yloUL5Fi8GTGY= 18 | github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= 19 | github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= 20 | github.com/go-pdf/fpdf v0.8.0 h1:IJKpdaagnWUeSkUFUjTcSzTppFxmv8ucGQyNPQWxYOQ= 21 | github.com/go-pdf/fpdf v0.8.0/go.mod h1:gfqhcNwXrsd3XYKte9a7vM3smvU/jB4ZRDrmWSxpfdc= 22 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 23 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 24 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 25 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 26 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 30 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 31 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 32 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 33 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 34 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 35 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 36 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 39 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 40 | golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI= 41 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= 42 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= 43 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 44 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 45 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 46 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 47 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 54 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 55 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 56 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 57 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 58 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 59 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 60 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 62 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= 64 | gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE= 65 | gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= 71 | rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= 72 | -------------------------------------------------------------------------------- /cmd/compare.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | "strings" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | var compareWith int 33 | 34 | // compareCmd represents the compare command 35 | var compareCmd = &cobra.Command{ 36 | Use: "compare", 37 | Short: "Compares two top Submitters extractions to show \"churned\" or \"new\" submitters.", 38 | Long: `The COMPARE command will will extract a the Top Submitters as with the EXTRACT command and than 39 | compare it with an extraction with the same settings but with an X amount of months before`, 40 | Args: func(cmd *cobra.Command, args []string) error { 41 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 42 | return err 43 | } 44 | if !isFileValid(args[0]) { 45 | return fmt.Errorf("Invalid input file\n") 46 | } 47 | if !isValidMonth(endMonth, isVerboseExtract) { 48 | return fmt.Errorf("\"%s\" is an invalid month\n", endMonth) 49 | } 50 | 51 | // check the input type 52 | switch strings.ToLower(argInputType) { 53 | case "submitters": 54 | inputType = InputTypeSubmitters 55 | case "commenters": 56 | inputType = InputTypeCommenters 57 | default: 58 | inputType = InputTypeUnknown 59 | } 60 | 61 | if inputType == InputTypeUnknown { 62 | return fmt.Errorf("%s is an invalid input type\n", argInputType) 63 | } 64 | 65 | return nil 66 | }, 67 | RunE: func(cmd *cobra.Command, args []string) error { 68 | // When called standalone, we want to give the minimal information 69 | isSilent := true 70 | 71 | inputPivotTableName := args[0] 72 | 73 | if !checkFile(inputPivotTableName, isSilent) { 74 | fmt.Print("Invalid input file.") 75 | os.Exit(1) 76 | } 77 | 78 | if outputFileName == "top-submitters_YYYY-MM.csv" { 79 | outputFileName = "top-submitters_" + strings.ToUpper(endMonth) + ".csv" 80 | } 81 | 82 | // Extract the data (with no offset) 83 | result, real_endDate, csv_output_slice := extractData(args[0], topSize, endMonth, period, 0, inputType, isVerboseExtract) 84 | if !result { 85 | return fmt.Errorf("Failed to extract data") 86 | } 87 | 88 | // Extract the data (with offset this time) 89 | result, _, csv_offset_output_slice := extractData(args[0], topSize, endMonth, period, compareWith, inputType, isVerboseExtract) 90 | if !result { 91 | return fmt.Errorf("Failed to extract offset-ted data") 92 | } 93 | 94 | enrichedExtractedData := compareExtractedData(csv_output_slice, csv_offset_output_slice, inputType) 95 | 96 | //FIXME: this seems duplicate with line 76 97 | 98 | //FIXME: change default filename when specifying another type of input 99 | // If the default value is specified, update that default with the month being used for the calculation 100 | if outputFileName == "top-submitters_YYYY-MM.csv" { 101 | outputFileName = "top-submitters_" + strings.ToUpper(endMonth) + ".csv" 102 | } 103 | isMDoutput := isWithMDfileExtension(outputFileName) 104 | 105 | if isVerboseExtract { 106 | fileTypeText := "(CSV format)" 107 | if isMDoutput { 108 | fileTypeText = "(Markdown format)" 109 | } 110 | fmt.Printf("Writing compare results to \"%s\" %s\n\n", outputFileName, fileTypeText) 111 | } 112 | 113 | // Check that the output directory exists 114 | dirErr := CheckDir(outputFileName) 115 | if dirErr != nil { 116 | return dirErr 117 | } 118 | 119 | if isMDoutput { 120 | introduction := "" 121 | if inputType == InputTypeSubmitters { 122 | introduction = "# Top Submitters (Compare)\n" 123 | buffer := fmt.Sprintf("\nExtraction of the %d top submitters (non-bot PR creators) \nover the %d months before \"%s\".\n", topSize, period, real_endDate) 124 | buffer = buffer + fmt.Sprintf("Table shows new and \"churned\" submitters compared \nto the situation %d months before.\n\n", compareWith) 125 | introduction = introduction + buffer 126 | } 127 | if inputType == InputTypeCommenters { 128 | introduction = "# Top Commenters (Compare)\n" 129 | buffer := fmt.Sprintf("\nExtraction of the %d top (non-bot) commenters \nover the %d months before \"%s\".\n", topSize, period, real_endDate) 130 | buffer = buffer + fmt.Sprintf("Table shows new and \"churned\" commenters compared \nto the situation %d months before.\n\n", compareWith) 131 | introduction = introduction + buffer 132 | } 133 | writeDataAsMarkdown(outputFileName, enrichedExtractedData, introduction, isOutputHistory, inputType) 134 | } else { 135 | writeCSVtoFile(outputFileName, enrichedExtractedData) 136 | } 137 | 138 | //if requested, write the history based the supplied top user slice 139 | if isOutputHistory { 140 | isCompare := true 141 | historyOutputFilename := generateHistoryFilename(outputFileName, inputType, isCompare) 142 | 143 | if err := writeHistoryOutput(historyOutputFilename, inputPivotTableName, inputType, enrichedExtractedData); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | }, 150 | } 151 | 152 | func init() { 153 | rootCmd.AddCommand(compareCmd) 154 | 155 | // Here you will define your flags and configuration settings. 156 | compareCmd.PersistentFlags().StringVarP(&outputFileName, "out", "o", "top-submitters_YYYY-MM.csv", "Output file name.") 157 | compareCmd.PersistentFlags().StringVarP(&argInputType, "type", "", "submitters", "The type of data being analyzed. Can be either \"submitters\" or \"commenters\"") 158 | compareCmd.PersistentFlags().IntVarP(&topSize, "topSize", "t", 35, "Number of top submitters to extract.") 159 | compareCmd.PersistentFlags().IntVarP(&period, "period", "p", 12, "Number of months to accumulate.") 160 | compareCmd.PersistentFlags().IntVarP(&compareWith, "compare", "c", 3, "Number of months back to compare with.") 161 | compareCmd.PersistentFlags().StringVarP(&endMonth, "month", "m", "latest", "Month to extract top submitters.") 162 | compareCmd.PersistentFlags().BoolVarP(&isOutputHistory, "history", "", false, "Outputs the available activity history for the top submitters") 163 | 164 | compareCmd.PersistentFlags().BoolVarP(&isVerboseExtract, "verbose", "v", false, "Displays useful info during the extraction") 165 | } 166 | 167 | func compareExtractedData(recentData [][]string, oldData [][]string, inputType InputType) (enrichedExtractedData [][]string) { 168 | var output_slice [][]string 169 | var header_row []string 170 | 171 | if inputType == InputTypeSubmitters { 172 | header_row = []string{"Submitter", "Total_PRs", "Status"} 173 | } 174 | if inputType == InputTypeCommenters { 175 | //FIXME: Check inconsistant capitalisation of Status 176 | header_row = []string{"Commenter", "Comments", "status"} 177 | } 178 | 179 | output_slice = append(output_slice, header_row) 180 | 181 | // Check for new submitters 182 | for i := range recentData { 183 | //skip the title 184 | if i == 0 { 185 | continue 186 | } 187 | 188 | status := "" 189 | if !isSubmitterFound(oldData, recentData[i][0]) { 190 | status = "new" 191 | } 192 | 193 | dataRow := []string{recentData[i][0], recentData[i][1], status} 194 | output_slice = append(output_slice, dataRow) 195 | } 196 | 197 | // Check for churned submitters 198 | for i := range oldData { 199 | //skip the title 200 | if i == 0 { 201 | continue 202 | } 203 | 204 | if !isSubmitterFound(recentData, oldData[i][0]) { 205 | dataRow := []string{oldData[i][0], "", "churned"} 206 | output_slice = append(output_slice, dataRow) 207 | } 208 | } 209 | return output_slice 210 | } 211 | 212 | // Check whether the submitter exists in the supplied dataset 213 | func isSubmitterFound(dataset [][]string, submitter string) (found bool) { 214 | for i := range dataset { 215 | if dataset[i][0] == submitter { 216 | return true 217 | } 218 | } 219 | return false 220 | } 221 | -------------------------------------------------------------------------------- /cmd/compare_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "bytes" 26 | "reflect" 27 | "strings" 28 | "testing" 29 | 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | var searchSubmitter_dataset = [][]string{ 34 | {"submitter", "PR"}, 35 | {"alpha", "0"}, 36 | {"bravo", "0"}, 37 | {"charly", "0"}, 38 | {"delta", "0"}, 39 | } 40 | 41 | func Test_isSubmitterFound(t *testing.T) { 42 | type args struct { 43 | dataset [][]string 44 | submitter string 45 | } 46 | tests := []struct { 47 | name string 48 | args args 49 | wantFound bool 50 | }{ 51 | { 52 | "happy case", 53 | args{dataset: searchSubmitter_dataset, submitter: "bravo"}, 54 | true, 55 | }, 56 | { 57 | "Not found", 58 | args{dataset: searchSubmitter_dataset, submitter: "foxtrot"}, 59 | false, 60 | }, 61 | } 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | if gotFound := isSubmitterFound(tt.args.dataset, tt.args.submitter); gotFound != tt.wantFound { 65 | t.Errorf("isSubmitterFound() = %v, want %v", gotFound, tt.wantFound) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | var dataset_1 = [][]string{ 72 | {"submitter", "PR"}, 73 | {"alpha", "1"}, 74 | {"bravo", "2"}, 75 | {"charly", "3"}, 76 | {"delta", "4"}, 77 | } 78 | var dataset_2 = [][]string{ 79 | {"submitter", "PR"}, 80 | {"alpha", "1"}, 81 | {"charly", "2"}, 82 | {"delta", "3"}, 83 | {"zebra", "4"}, 84 | } 85 | 86 | var dataset_result = [][]string{ 87 | {"Submitter", "Total_PRs", "Status"}, 88 | {"alpha", "1", ""}, 89 | {"bravo", "2", "new"}, 90 | {"charly", "3", ""}, 91 | {"delta", "4", ""}, 92 | {"zebra", "", "churned"}, 93 | } 94 | 95 | func Test_compareExtractedData(t *testing.T) { 96 | type args struct { 97 | recentData [][]string 98 | oldData [][]string 99 | inputType InputType 100 | } 101 | tests := []struct { 102 | name string 103 | args args 104 | wantEnrichedExtractedData [][]string 105 | }{ 106 | { 107 | "case 1", 108 | args{recentData: dataset_1, oldData: dataset_2, inputType: InputTypeSubmitters}, 109 | dataset_result, 110 | }, 111 | } 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | if gotEnrichedExtractedData := compareExtractedData(tt.args.recentData, tt.args.oldData, tt.args.inputType); !reflect.DeepEqual(gotEnrichedExtractedData, tt.wantEnrichedExtractedData) { 115 | t.Errorf("compareExtractedData() = %v, want %v", gotEnrichedExtractedData, tt.wantEnrichedExtractedData) 116 | } 117 | }) 118 | } 119 | } 120 | 121 | func Test_ExecuteCommentersCompareToMarkdown_integrationTest(t *testing.T) { 122 | // Setup test environment 123 | tempDir := t.TempDir() 124 | testOutputFilename := tempDir + "extract_markdown_output.md" 125 | goldenMarkdownFilename, err := duplicateFile("../test_data/compare-commenters_reference_output.md", tempDir) 126 | 127 | assert.NoError(t, err, "Unexpected Golden File duplication error") 128 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 129 | 130 | // setup the command line 131 | actual := new(bytes.Buffer) 132 | rootCmd.SetOut(actual) 133 | rootCmd.SetErr(actual) 134 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "--month=latest", "--period=12", "--topSize=35", "--compare=3", "--type=commenters", "--out=" + testOutputFilename}) 135 | 136 | // Execute the module under test 137 | error := rootCmd.Execute() 138 | 139 | // Check the results 140 | assert.NoError(t, error, "Unexpected failure") 141 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 142 | } 143 | 144 | func Test_ExecuteSubmitterCompareToMarkdown_integrationTest(t *testing.T) { 145 | // Setup test environment 146 | tempDir := t.TempDir() 147 | testOutputFilename := tempDir + "extract_markdown_output.md" 148 | goldenMarkdownFilename, err := duplicateFile("../test_data/compare-submitters_reference_output.md", tempDir) 149 | 150 | assert.NoError(t, err, "Unexpected Golden File duplication error") 151 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 152 | 153 | // setup the command line 154 | actual := new(bytes.Buffer) 155 | rootCmd.SetOut(actual) 156 | rootCmd.SetErr(actual) 157 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "--month=latest", "--period=12", "--topSize=35", "--compare=3", "--type=submitters", "--out=" + testOutputFilename}) 158 | 159 | // Execute the module under test 160 | error := rootCmd.Execute() 161 | 162 | // Check the results 163 | assert.NoError(t, error, "Unexpected failure") 164 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 165 | } 166 | 167 | func Test_ExecuteCompareWithUnknownInputType_mustFail(t *testing.T) { 168 | // setup the command line 169 | actual := new(bytes.Buffer) 170 | rootCmd.SetOut(actual) 171 | rootCmd.SetErr(actual) 172 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "--type=blaah"}) 173 | 174 | // Execute the module under test 175 | error := rootCmd.Execute() 176 | 177 | assert.Error(t, error, "Function call should have failed") 178 | 179 | //Error is expected 180 | expectedMsg := "Error: blaah is an invalid input type" 181 | lines := strings.Split(actual.String(), "\n") 182 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 183 | } 184 | 185 | func Test_ExecuteCompareWithInvalidOutputDir_mustFail(t *testing.T) { 186 | // setup the command line 187 | actual := new(bytes.Buffer) 188 | rootCmd.SetOut(actual) 189 | rootCmd.SetErr(actual) 190 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "--type=submitters", "--out=./inexistant/directory/output.csv"}) 191 | 192 | // Execute the module under test 193 | error := rootCmd.Execute() 194 | 195 | assert.Error(t, error, "Function call should have failed") 196 | 197 | //Error is expected 198 | expectedMsg := "Error: The directory of specified output file (inexistant/directory) does not exist." 199 | lines := strings.Split(actual.String(), "\n") 200 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 201 | } 202 | 203 | func Test_CompareExtractWithNoArgs_mustFail(t *testing.T) { 204 | // setup the command line 205 | actual := new(bytes.Buffer) 206 | rootCmd.SetOut(actual) 207 | rootCmd.SetErr(actual) 208 | rootCmd.SetArgs([]string{"compare"}) 209 | 210 | // Execute the module under test 211 | error := rootCmd.Execute() 212 | 213 | assert.Error(t, error, "Function call should have failed") 214 | 215 | //Error is expected 216 | expectedMsg := "Error: requires at least 1 arg(s), only received 0" 217 | lines := strings.Split(actual.String(), "\n") 218 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 219 | } 220 | 221 | func Test_CompareExtractWithInvalidInputFile_mustFail(t *testing.T) { 222 | // setup the command line 223 | actual := new(bytes.Buffer) 224 | rootCmd.SetOut(actual) 225 | rootCmd.SetErr(actual) 226 | rootCmd.SetArgs([]string{"compare", "nonExistantFile.csv"}) 227 | 228 | // Execute the module under test 229 | error := rootCmd.Execute() 230 | 231 | assert.Error(t, error, "Function call should have failed") 232 | 233 | //Error is expected 234 | expectedMsg := "Error: Invalid input file" 235 | lines := strings.Split(actual.String(), "\n") 236 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 237 | } 238 | 239 | func Test_CompareExtractWithInvalidEndMonth_mustFail(t *testing.T) { 240 | // setup the command line 241 | actual := new(bytes.Buffer) 242 | rootCmd.SetOut(actual) 243 | rootCmd.SetErr(actual) 244 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "-m=junkMonth"}) 245 | 246 | // Execute the module under test 247 | error := rootCmd.Execute() 248 | 249 | assert.Error(t, error, "Function call should have failed") 250 | 251 | //Error is expected 252 | expectedMsg := "Error: \"junkMonth\" is an invalid month" 253 | lines := strings.Split(actual.String(), "\n") 254 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 255 | } 256 | 257 | func Test_ExecuteSubmitterCompareWithHistory_integrationTest(t *testing.T) { 258 | // Setup test environment 259 | tempDir := t.TempDir() 260 | testOutputFilename := tempDir + "/extract_markdown_output.md" 261 | expectedHistoryFilename := tempDir + "/top_submitters_evolution_fullHistory.csv" 262 | 263 | goldenMarkdownFilename, err := duplicateFile("../test_data/compare-submitters_history_reference_output.md", tempDir) 264 | assert.NoError(t, err, "Unexpected Golden File duplication error") 265 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 266 | 267 | goldenHistoryFilename, err2 := duplicateFile("../test_data/historicCompare_Integration_reference.csv", tempDir) 268 | assert.NoError(t, err2, "Unexpected Golden History File duplication error") 269 | assert.NotEmpty(t, goldenHistoryFilename, "Failure to duplicate Golden History File") 270 | 271 | // setup the command line 272 | actual := new(bytes.Buffer) 273 | rootCmd.SetOut(actual) 274 | rootCmd.SetErr(actual) 275 | rootCmd.SetArgs([]string{"compare", "../test_data/overview.csv", "--history", "--month=latest", "--period=12", "--topSize=35", "--compare=3", "--type=submitters", "--out=" + testOutputFilename}) 276 | 277 | // Execute the module under test 278 | error := rootCmd.Execute() 279 | 280 | // Check the results 281 | assert.NoError(t, error, "Unexpected failure") 282 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 283 | assert.FileExists(t, expectedHistoryFilename, "history file was not produced") 284 | assert.NoError(t, isFileEquivalent(expectedHistoryFilename, goldenHistoryFilename)) 285 | } 286 | 287 | //TODO: validate CSV output 288 | -------------------------------------------------------------------------------- /cmd/utilities.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | 23 | package cmd 24 | 25 | import ( 26 | "bufio" 27 | "encoding/csv" 28 | "fmt" 29 | "log" 30 | "os" 31 | "path/filepath" 32 | "regexp" 33 | "strconv" 34 | "strings" 35 | ) 36 | 37 | // Validates that the input file is a real file (and not a directory) 38 | func isFileValid(fileName string) bool { 39 | info, err := os.Stat(fileName) 40 | if os.IsNotExist(err) { 41 | return false 42 | } 43 | return !info.IsDir() 44 | } 45 | 46 | // validates whether the month parameter has the correct format ("YYYY-MM" or "latest") 47 | func isValidMonth(month string, isVerbose bool) bool { 48 | if month == "" { 49 | if isVerbose { 50 | fmt.Print("Empty month\n") 51 | } 52 | return false 53 | } 54 | if strings.ToUpper(month) == "LATEST" { 55 | return true 56 | } 57 | 58 | regexpMonth := regexp.MustCompile(`20[12][0-9]-(0[1-9]|1[0-2])`) 59 | if !regexpMonth.MatchString(month) { 60 | if isVerbose { 61 | fmt.Printf("Supplied data (%s) is not in a valid month format. Should be \"YYYY-MM\" and later than 2010\n", month) 62 | } 63 | return false 64 | } 65 | 66 | return true 67 | } 68 | 69 | // Write the string slice to a file formatted as a CSV 70 | func writeCSVtoFile(outputFileName string, csv_output_slice [][]string) { 71 | //Open output file 72 | out, err := os.Create(outputFileName) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | defer out.Close() 77 | 78 | //Write the collected data as a CSV file 79 | csv_out := csv.NewWriter(out) 80 | write_err := csv_out.WriteAll(csv_output_slice) 81 | if write_err != nil { 82 | log.Fatal(err) 83 | } 84 | csv_out.Flush() 85 | } 86 | 87 | // returns true if the file extension is .md. 88 | // It returns false in other cases, thus assuming a CSV output 89 | func isWithMDfileExtension(filename string) bool { 90 | extension := filepath.Ext(filename) 91 | if strings.ToLower(extension) == ".md" { 92 | return true 93 | } else { 94 | return false 95 | } 96 | } 97 | 98 | // TODO: externalize the header creation 99 | // TODO: return error 100 | // Writes the data as Markdown 101 | func writeDataAsMarkdown(outputFileName string, output_data_slice [][]string, introductionText string, isHistory bool, inputType InputType, ) { 102 | //Open output file 103 | f, err := os.Create(outputFileName) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | defer f.Close() 108 | out := bufio.NewWriter(f) 109 | 110 | width_slice, err := get_columnsWidth(output_data_slice) 111 | if err != nil { 112 | log.Fatal(err) 113 | } 114 | 115 | //Write the intro text if present 116 | if len(introductionText) > 0 { 117 | fmt.Fprintf(out, "%s\n", introductionText) 118 | } 119 | 120 | // set the plot directory name based on the data type (submitters or commenters) 121 | plot_dir := "" 122 | if inputType == InputTypeCommenters { 123 | //FIXME: should be a global constant (used in the graph generation and in MD generation) 124 | plot_dir = "commentersPlot" 125 | } else { 126 | plot_dir = "plot" 127 | } 128 | 129 | for lineNumber, dataLine := range output_data_slice { 130 | //Are we dealing with the title (and underline) ? 131 | isHeaderUnderline := false 132 | if lineNumber == 1 { 133 | isHeaderUnderline = true 134 | } 135 | 136 | writeBuffer := "|" 137 | underlineBuffer := "|" 138 | for columnNbr, data := range dataLine { 139 | //Check whether the value is numerical (we don't treat the case of float data) 140 | _, atoi_err := strconv.Atoi(data) 141 | exact_width := 0 142 | if atoi_err != nil { 143 | //not integer -> left align 144 | exact_width = 0 - width_slice[columnNbr] 145 | } else { 146 | //Integer -> right align 147 | exact_width = width_slice[columnNbr] 148 | } 149 | 150 | // We are dealing with the logic of the underline 151 | headerUnderline := "" 152 | if isHeaderUnderline { 153 | if exact_width <= 0 { 154 | headerUnderline = strings.Repeat("-", width_slice[columnNbr]) 155 | } else { 156 | headerUnderline = strings.Repeat("-", width_slice[columnNbr]-1) + ":" 157 | } 158 | underlineBuffer = underlineBuffer + " " + headerUnderline + " |" 159 | } 160 | 161 | // isHistory means that the history (and plots) is generated along the MD. 162 | //This means that we need to create a link to the plots 163 | formattedData := "" 164 | if isHistory && (columnNbr == 0) && (lineNumber != 0){ 165 | //data contains the user name (eventually enriched) 166 | name_element := strings.Split(data, " ") 167 | cleanedName := name_element[0] 168 | 169 | formattedData = fmt.Sprintf(" [%s](%s/%s.png)", data, plot_dir,cleanedName) 170 | } else { 171 | formattedData = fmt.Sprintf(" %*s", exact_width, data) 172 | } 173 | writeBuffer = writeBuffer + formattedData + " |" 174 | } 175 | if isHeaderUnderline { 176 | fmt.Fprint(out, underlineBuffer+"\n") 177 | } 178 | fmt.Fprint(out, writeBuffer+"\n") 179 | } 180 | 181 | out.Flush() 182 | } 183 | 184 | // Returns a list of the maximum width of data supplied in data slice 185 | func get_columnsWidth(output_data_slice [][]string) (width_slice []int, err error) { 186 | 187 | announced_nbr_columns := len(output_data_slice[0]) 188 | for i := 0; i < announced_nbr_columns; i++ { 189 | width_slice = append(width_slice, 0) 190 | } 191 | 192 | //Walk through every line 193 | for lineNbr, slice_line := range output_data_slice { 194 | //Check column numbers for mismatch 195 | nbr_columns := len(output_data_slice[lineNbr]) 196 | if nbr_columns != announced_nbr_columns { 197 | err = fmt.Errorf("line #%d has %d column while expecting %d \n", lineNbr+1, nbr_columns, announced_nbr_columns) 198 | return nil, err 199 | } 200 | 201 | //get the size of each data cell and update the counter slice if necessary 202 | for columnNbr, data_cell := range slice_line { 203 | if len(data_cell) > width_slice[columnNbr] { 204 | width_slice[columnNbr] = len(data_cell) 205 | } 206 | } 207 | } 208 | return width_slice, nil 209 | } 210 | 211 | // CheckDir verifies a given path/file string actually exists. If it does not 212 | // then exit with an error. 213 | func CheckDir(file string) error { 214 | path := filepath.Dir(file) 215 | if _, err := os.Stat(path); err != nil { 216 | if os.IsNotExist(err) { 217 | return fmt.Errorf("The directory of specified output file (%s) does not exist.", path) 218 | } 219 | } 220 | return nil 221 | } 222 | 223 | // Based on the requested output filename (pivot table), builds a filename to store the history 224 | func generateHistoryFilename(outputFilename string, dataType InputType, isCompare bool) (historyFilename string) { 225 | 226 | //Get the path part from the output filename 227 | path := filepath.Dir(outputFilename) 228 | 229 | //Compute filename elements based on parameters 230 | historyFilenameType := "" 231 | if dataType == InputTypeCommenters { 232 | historyFilenameType = "commenters" 233 | } else { 234 | historyFilenameType = "submitters" 235 | } 236 | extractType := "" 237 | if isCompare { 238 | extractType = "_evolution" 239 | } 240 | 241 | historyFilename = path + "/" + "top_" + historyFilenameType + extractType + "_fullHistory.csv" 242 | 243 | return historyFilename 244 | } 245 | 246 | // Will retrieve and write the history line for all the top users 247 | func writeHistoryOutput(historyOutputFilename string, inputFilename string, dataType InputType, csv_output_slice [][]string) (err error) { 248 | 249 | // Check is the csv_output_slice is at least 1 record + tile long 250 | if len(csv_output_slice) <= 2 { 251 | return fmt.Errorf("The generated top user data seems empty.") 252 | } 253 | 254 | // Are we dealing with COMPARE type output (it has three columns)? 255 | // Note: this could have been a parameter for robustness. Can be refactored later (TODO:) 256 | isCompare := false 257 | expectedCompareColumnTitle := "status" 258 | if len(csv_output_slice[0]) == 3 { 259 | isCompare = true 260 | if strings.ToLower(csv_output_slice[0][2]) != expectedCompareColumnTitle { 261 | return fmt.Errorf("COMPARE output check failure: found three columns but third one doesn't have the expected title (found \"%s\" instead of \"%s\")", csv_output_slice[0][2], expectedCompareColumnTitle) 262 | } 263 | } 264 | 265 | // Load the pivot table in memory 266 | pivotRecords, loadErr := loadInputPivotTable(inputFilename) 267 | if loadErr != nil { 268 | return loadErr 269 | } 270 | 271 | //do we have data in the pivot table ? 272 | if len(pivotRecords) <= 2 { 273 | return fmt.Errorf("The pivot table (%s) seems empty.", inputFilename) 274 | } 275 | 276 | //This is a new slice that will contain the data to write 277 | var historicDataSlice [][]string 278 | 279 | //Get the title line and add it to the output 280 | historicDataSlice = append(historicDataSlice, pivotRecords[0]) 281 | 282 | for topUser_index, topUser_line := range csv_output_slice { 283 | 284 | //We are dealing with the title line that we just want to skip 285 | if topUser_index == 0 { 286 | continue 287 | } 288 | 289 | //get the line index of the line containing the top user's data 290 | name := topUser_line[0] 291 | index := getIndexInPivotTable(pivotRecords, name) 292 | 293 | //check that return value is not negative (not found) 294 | if index == -1 { 295 | return fmt.Errorf("Supplied name (%s) was not found in input pivot table file", name) 296 | } 297 | 298 | // If we are dealing with a Compare output we need to update the user handle with its status 299 | fullUsername := name 300 | if isCompare { 301 | //We need to update the name with the status (if there is one) 302 | if topUser_line[2] != "" { 303 | fullUsername = name + " (" + topUser_line[2] + ")" 304 | } 305 | } 306 | 307 | //Update the pivot record with the (possibly) updated name 308 | pivotRecords[index][0] = fullUsername 309 | 310 | // Add the collected data 311 | historicDataSlice = append(historicDataSlice, pivotRecords[index]) 312 | } 313 | 314 | //figure out what the output directory is 315 | historyBasePath := filepath.Dir(historyOutputFilename) 316 | plotPath := "" 317 | if dataType == InputTypeCommenters { 318 | plotPath = filepath.Join(historyBasePath, "commentersPlot") 319 | } else { 320 | plotPath = filepath.Join(historyBasePath, "plot") 321 | } 322 | 323 | 324 | //Create it as it doesn't exist and plot doesn't like that. 325 | err = os.MkdirAll(plotPath, os.ModePerm) 326 | if err != nil { 327 | return fmt.Errorf("Failed to create the plot directory: %v", err) 328 | } 329 | 330 | //generate graphics 331 | err = plotAllHistoryFiles(plotPath, historicDataSlice, dataType) 332 | if err != nil { 333 | return err 334 | } 335 | 336 | //Write the CSV 337 | writeCSVtoFile(historyOutputFilename, historicDataSlice) 338 | 339 | return nil 340 | } 341 | 342 | // returns the index in the pivot record's slice with the supplied name. 343 | // Returns -1 if not found 344 | func getIndexInPivotTable(pivotRecords [][]string, name string) (index int) { 345 | index = -1 346 | 347 | for indexNbr, line := range pivotRecords { 348 | if line[0] == name { 349 | return indexNbr 350 | } 351 | } 352 | 353 | return index 354 | 355 | } 356 | -------------------------------------------------------------------------------- /test_data/short_overview.csv: -------------------------------------------------------------------------------- 1 | ,"2020-01","2020-02","2020-03","2020-04","2020-05","2020-06","2020-07","2020-08","2020-09","2020-10","2020-11","2020-12","2021-01","2021-02","2021-03","2021-04","2021-05","2021-06","2021-07","2021-08","2021-09","2021-10","2021-11","2021-12","2022-01","2022-02","2022-03","2022-04","2022-05","2022-06","2022-07","2022-08","2022-09","2022-10","2022-11","2022-12","2023-01","2023-02","2023-03","2023-04" 2 | "0x41head",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,14,13,12,11,10,9,8,7,6,5,4,3,2,1 3 | "0xDAFE",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 4 | "11000100111000",0,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 5 | "1c3t3a",0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 6 | "2012ucp1544",0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 7 | "321ravi",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 8 | "4n70w4",0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 9 | "4x0v7",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0 10 | "613andred",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 11 | "95jonpet",0,0,0,0,3,0,1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 12 | "ADI10HERO",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,12,5,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 13 | "ADITYADAS1999",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0 14 | "APEdevelopment",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 15 | "ARUNMOHANRAJ471",0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 16 | "AScripnic",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 17 | "AaronZurawski",0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 18 | "AayushSaini101",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,9,4,0,0,0,0,0,0 19 | "AbelNavarro",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 20 | "AbhinavSinha12",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0 21 | "Abhishekverma2002",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1 22 | "AbhyudayaSharma",0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 23 | "Abingcbc",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0 24 | "AboorvaDevarajan",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0 25 | "Absh-Day",0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,2,0,0,1,0,0,0,0 26 | "Acaceres1996",0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 27 | "Adakar",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,0,0,1,0,0,0,0,0 28 | "AdamBrousseau",0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 29 | "AdrianFarmadin",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0 30 | "AfiMaameDufie",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0 31 | "Aga303",0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 32 | "AgyaeyTiwari",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1 33 | "Ahmed-Sellami",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0 34 | "Ajaypathak372",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0 35 | "AjeetGupta",0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 36 | "AkhilJoseph-Valeo",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0 37 | "Aki-7",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,14,3,20,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 38 | "AlbanAndrieu",0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0 39 | "Alceatraz",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0 40 | "AleMartinez-Devops",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0 41 | "AlessandroMenti",0,0,0,0,0,1,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 42 | "Alex-Vol",0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 43 | "Alex-Vol-SV",0,0,0,0,0,0,2,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 44 | "Alex-Weatherhead",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 45 | "Alex0Blackwell",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0 46 | "Alexander-Paeshin",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0 47 | "AlexanderNikitin-Smartbear",0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 48 | "AlexanderStohr",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,2,0,0,0 49 | "AlexanderYeremeyev",0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 50 | "AlexandreAllemand",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0 51 | "AlexandruMaxim",0,0,0,0,0,0,0,0,0,0,0,2,0,0,3,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 52 | "AlexisMtr",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 53 | "AlienHoboken",0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 54 | "Alirezaaraby",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 55 | "AllenJB",1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0 56 | "AlvaroRausell",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 57 | "AlvinStanescu",0,0,0,4,0,0,4,0,0,1,1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 58 | "AmanGupta677",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0 59 | "Amanjain4269",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0 60 | "Amerousful",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 61 | "AmeyaJoshi1",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 62 | "AmineAML",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 63 | "AmirWiener",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0 64 | "AnalogJ",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 65 | "AnastasiaKozachuk",1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 66 | "AndKiel",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0 67 | "AnderwanSAM",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 68 | "Andre-K42",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 69 | "AndreBrinkop",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0 70 | "AndreGCX",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0 71 | "AndresFPineros",0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 72 | "AndreyLevchenko",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0 73 | "AndriiChuzhynov",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0 74 | "Andy-GetPostman",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 75 | "Andy2003",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0 76 | "Angelin01",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 77 | "AnishaGharat",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0 78 | "Ankit098",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0 79 | "AnsisMalins",0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 80 | "AnubhavHawk",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 81 | "Anuj-Kumar-AJ",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0 82 | "AparnaSaripaka",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0 83 | "Ar-Kan",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0 84 | "Archish27",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 85 | "ArieLevs",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 86 | "ArielExperitest",0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 87 | "Arne2",0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 88 | "ArnoACS",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 89 | "Artmorse",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0 90 | "ArturHarasimiuk",0,0,2,4,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 91 | "Atzmon-hentov",0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 92 | "Aurel",0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 93 | "Avielyo10",0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 94 | "Avishagp",0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 95 | "Avni-Sharma",0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 96 | "Azleal",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0 97 | "Azrael808",0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 98 | "Bakies",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 99 | "Bananeweizen",0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 100 | "BearSpry",0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 101 | "BenjaminCCross",0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 102 | "Benjaminvdv",0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 103 | "Bevigil",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0 104 | "Bharath-Ganesh",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1 105 | "BhaswatiRoy",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0 106 | "Bilgetz",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,1,0,0,0 107 | "BirSikanderMahajan",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 108 | "Bkocsan",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 109 | "BlueDragn",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0 110 | "BogdanLivadariu",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1 111 | "Bomme",0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 112 | "Bonifacio2",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0 113 | "BorisYaoA",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,1,0,0,0,0,0 114 | "Bortulev",0,3,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 115 | "BradNewton",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 116 | "BradPatras",0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 117 | "BradyShober",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 118 | "Brenne",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 119 | "Brick7Face",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 120 | "Brunochris13",0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 121 | "BurkHufnagel",0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 122 | "CCFenner",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0 123 | "CJCombrink",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0 124 | "CWJoseph",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0 125 | "Caellion",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 126 | "CarbonCopy4V",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0 127 | "CatherineKiiru",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,4,0,0,0,0,0,0,0,0,0,0,0 128 | "Celebrate-future",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 129 | "ChadiEM",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,2,0,0,1,1,0,0,0,0,0,1 130 | "ChampiYann",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0 131 | "Chandu-4444",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 132 | "CharlieTLe",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 133 | "ChenZhangg",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 134 | "ChrisRBe",0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 135 | "ChrisRo89",0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 136 | "ChristineTChen",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 137 | "ClaudiuCudla",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 138 | "ClaytonHughes",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 139 | "ClementDEBOOS",0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 140 | -------------------------------------------------------------------------------- /cmd/extract.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "encoding/csv" 26 | "fmt" 27 | "log" 28 | "os" 29 | "sort" 30 | "strconv" 31 | "strings" 32 | 33 | "github.com/spf13/cobra" 34 | ) 35 | 36 | // Variables set from the command line 37 | var outputFileName string 38 | var topSize int 39 | var period int 40 | var endMonth string 41 | var isVerboseExtract bool 42 | var argInputType string 43 | var isOutputHistory bool 44 | var inputType InputType 45 | 46 | type InputType uint8 47 | 48 | const ( 49 | InputTypeUnknown InputType = iota 50 | InputTypeSubmitters 51 | InputTypeCommenters 52 | ) 53 | 54 | type totalized_record struct { 55 | User string //Submitter name 56 | Pr int //Number of PRs 57 | } 58 | 59 | // extractCmd represents the extract command 60 | var extractCmd = &cobra.Command{ 61 | Use: "extract [input file]", 62 | Short: "Extracts the top submitters from the supplied pivot table", 63 | Long: `This command extract the top submitter for a given period (by default 12 months). 64 | This interval is counted, by default, from the last month available in the pivot table. 65 | The input file is first validated before being processed. 66 | 67 | If not specified, the output file name is hardcoded to "top-submitters_YYYY-MM.csv". 68 | The "YYYY-MM" stands for the specified end month (see "--month" flag). It is "LATEST" 69 | if not end month was specified (default). 70 | 71 | The "months" parameter is the number of months used to compute the top users, 72 | counting from backwards from the last month. If a 0 months is specified, all the 73 | available months are counted. 74 | 75 | The "topSize" parameter defines the number of users considered as top users. 76 | If more submitters with the same amount of total PRs exist ("ex aequo"), they are included in 77 | the list (resulting in more thant the specified number of top users). 78 | `, 79 | Args: func(cmd *cobra.Command, args []string) error { 80 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 81 | return err 82 | } 83 | if !isFileValid(args[0]) { 84 | return fmt.Errorf("Invalid input file\n") 85 | } 86 | if !isValidMonth(endMonth, isVerboseExtract) { 87 | return fmt.Errorf("\"%s\" is an invalid month\n", endMonth) 88 | } 89 | 90 | // check the input type 91 | switch strings.ToLower(argInputType) { 92 | case "submitters": 93 | inputType = InputTypeSubmitters 94 | case "commenters": 95 | inputType = InputTypeCommenters 96 | default: 97 | inputType = InputTypeUnknown 98 | } 99 | 100 | if inputType == InputTypeUnknown { 101 | return fmt.Errorf("%s is an invalid input type\n", argInputType) 102 | } 103 | 104 | return nil 105 | }, 106 | RunE: func(cmd *cobra.Command, args []string) error { 107 | // When called standalone, we want to give the minimal information 108 | isSilent := true 109 | 110 | inputPivotTableName := args[0] 111 | 112 | // Check input file 113 | if !checkFile(inputPivotTableName, isSilent) { 114 | return fmt.Errorf("Invalid input file.") 115 | } 116 | 117 | // Extract the data (with no offset) 118 | result, real_endDate, csv_output_slice := extractData(inputPivotTableName, topSize, endMonth, period, 0, inputType, isVerboseExtract) 119 | if !result { 120 | return fmt.Errorf("Failed to extract data") 121 | } 122 | 123 | //FIXME: change default filename when specifying another type of input 124 | // If the default value is specified, update that default with the month being used for the calculation 125 | if outputFileName == "top-submitters_YYYY-MM.csv" { 126 | outputFileName = "top-submitters_" + strings.ToUpper(endMonth) + ".csv" 127 | } 128 | isMDoutput := isWithMDfileExtension(outputFileName) 129 | 130 | if isVerboseExtract { 131 | fileTypeText := "(CSV format)" 132 | if isMDoutput { 133 | fileTypeText = "(Markdown format)" 134 | } 135 | fmt.Printf("Writing extraction to \"%s\" %s\n\n", outputFileName, fileTypeText) 136 | } 137 | 138 | // Check that the output directory exists 139 | dirErr := CheckDir(outputFileName) 140 | if dirErr != nil { 141 | return dirErr 142 | } 143 | 144 | if isMDoutput { 145 | introduction := "" 146 | if inputType == InputTypeSubmitters { 147 | introduction = "# Top Submitters\n" 148 | buffer := fmt.Sprintf("\nExtraction of the %d top submitters (non-bot PR creators) \nover the %d months before \"%s\".\n\n", topSize, period, real_endDate) 149 | introduction = introduction + buffer 150 | } 151 | if inputType == InputTypeCommenters { 152 | introduction = "# Top Commenters\n" 153 | buffer := fmt.Sprintf("\nExtraction of the %d top (non-bot) commenters \nover the %d months before \"%s\".\n\n", topSize, period, real_endDate) 154 | introduction = introduction + buffer 155 | } 156 | writeDataAsMarkdown(outputFileName, csv_output_slice, introduction, isOutputHistory, inputType) 157 | } else { 158 | writeCSVtoFile(outputFileName, csv_output_slice) 159 | } 160 | 161 | //if requested, write the history based the supplied top user slice 162 | if isOutputHistory { 163 | isCompare := false 164 | historyOutputFilename := generateHistoryFilename(outputFileName, inputType, isCompare) 165 | 166 | if err := writeHistoryOutput(historyOutputFilename, inputPivotTableName, inputType, csv_output_slice); err != nil { 167 | return err 168 | } 169 | } 170 | 171 | return nil 172 | }, 173 | } 174 | 175 | // Initialize the Cobra processor 176 | func init() { 177 | rootCmd.AddCommand(extractCmd) 178 | 179 | // definition of flags and configuration settings. 180 | extractCmd.PersistentFlags().StringVarP(&outputFileName, "out", "o", "top-submitters_YYYY-MM.csv", "Output file name. Using the \".md\" extension will generate a markdown file ") 181 | extractCmd.PersistentFlags().StringVarP(&argInputType, "type", "", "submitters", "The type of data being analyzed. Can be either \"submitters\" or \"commenters\"") 182 | extractCmd.PersistentFlags().IntVarP(&topSize, "topSize", "t", 35, "Number of top submitters to extract.") 183 | extractCmd.PersistentFlags().IntVarP(&period, "period", "p", 12, "Number of months to accumulate.") 184 | extractCmd.PersistentFlags().StringVarP(&endMonth, "month", "m", "latest", "Month to extract top submitters.") 185 | extractCmd.PersistentFlags().BoolVarP(&isOutputHistory, "history", "", false, "Outputs the available activity history for the top submitters") 186 | 187 | extractCmd.PersistentFlags().BoolVarP(&isVerboseExtract, "verbose", "v", false, "Displays useful info during the extraction") 188 | } 189 | 190 | // Extracts the top submitters for a given period and writes it to a file. 191 | // Offset defines the number of months before the specified endMonth the extraction must be done (needed for the COMPARE command). 192 | func extractData(inputFilename string, topSize int, endMonth string, period int, offset int, inputType InputType, isVerboseExtract bool) (result bool, real_endDate string, outputSlice [][]string) { 193 | if isVerboseExtract { 194 | fmt.Printf("Extracting from \"%s\" the %d top submitters during the last %d months\n\n", inputFilename, topSize, period) 195 | } 196 | 197 | records, loadErr := loadInputPivotTable(inputFilename) 198 | if loadErr != nil { 199 | return false, "", nil 200 | } 201 | 202 | firstDataColumn, lastDataColumn, oldestDate, mostRecentDate := getBoundaries(records, endMonth, period, offset) 203 | 204 | if strings.ToUpper(endMonth) != "LATEST" { 205 | if endMonth != mostRecentDate { 206 | log.Printf("Unexpected error computing boundaries (\"%s\" != \"%s\"\n", endMonth, mostRecentDate) 207 | return false, "", nil 208 | } 209 | } 210 | 211 | //We need to make that information available to caller 212 | real_endDate = mostRecentDate 213 | 214 | fmt.Printf("Accumulating data between %s and %s (columns %d and %d)\n", 215 | oldestDate, mostRecentDate, firstDataColumn, lastDataColumn) 216 | 217 | //Slice that will contain all the totalized records 218 | var new_output_slice []totalized_record 219 | 220 | for i, dataLine := range records { 221 | 222 | //Skip header line as it has already been checked 223 | if i == 0 { 224 | continue 225 | } 226 | 227 | recordTotal := 0 228 | for ii, column := range dataLine { 229 | if ii >= firstDataColumn && ii <= lastDataColumn { 230 | // fmt.Printf(", %s", column) 231 | 232 | // We don't treat conversion errors or negative values as the file has already been checked 233 | columnValue, _ := strconv.Atoi(column) 234 | recordTotal = recordTotal + columnValue 235 | } 236 | } 237 | 238 | //Add the total to the full list 239 | a_totalized_record := totalized_record{dataLine[0], recordTotal} 240 | new_output_slice = append(new_output_slice, a_totalized_record) 241 | } 242 | 243 | // Sort the slice, based on the number of PRs, in descending order 244 | sort.Slice(new_output_slice, func(i, j int) bool { return new_output_slice[i].Pr > new_output_slice[j].Pr }) 245 | 246 | //Loop through list to find the top submitters (and ex-aequo) to load the final list 247 | current_total := 0 248 | isListComplete := false 249 | 250 | var csv_output_slice [][]string 251 | var header_row []string 252 | 253 | if inputType == InputTypeSubmitters { 254 | header_row = []string{"Submitter", "Total_PRs"} 255 | } 256 | if inputType == InputTypeCommenters { 257 | header_row = []string{"Commenter", "Total_Comments"} 258 | } 259 | 260 | csv_output_slice = append(csv_output_slice, header_row) 261 | for i, total_record := range new_output_slice { 262 | if i < topSize { 263 | current_total = total_record.Pr 264 | 265 | var work_row []string 266 | work_row = append(work_row, total_record.User, strconv.Itoa(total_record.Pr)) 267 | csv_output_slice = append(csv_output_slice, work_row) 268 | } else { 269 | if !isListComplete { 270 | if current_total == total_record.Pr { 271 | //This is an ex-aequo, so add it to the list 272 | var work_row []string 273 | work_row = append(work_row, total_record.User, strconv.Itoa(total_record.Pr)) 274 | csv_output_slice = append(csv_output_slice, work_row) 275 | } else { 276 | // we have all we need 277 | isListComplete = true 278 | } 279 | } 280 | } 281 | } 282 | 283 | return true, real_endDate, csv_output_slice 284 | } 285 | 286 | // Opens and reads the input as a CSV file 287 | func loadInputPivotTable(inputFilename string) (loadedRecords [][]string, err error) { 288 | //TODO: add some unit tests here ? 289 | //At this stage of the processing, we assume that the input file is correctly formatted 290 | f, err := os.Open(inputFilename) 291 | if err != nil { 292 | return nil, fmt.Errorf("Unable to read input file "+inputFilename+"\n", err) 293 | } 294 | defer f.Close() 295 | 296 | r := csv.NewReader(f) 297 | records, err := r.ReadAll() 298 | if err != nil { 299 | return nil, fmt.Errorf("Unexpected error loading"+inputFilename+"\n", err) 300 | } 301 | 302 | return records, nil 303 | } 304 | 305 | // Based on the number of months requested, computes the start/end column and associated date for the given dataset. 306 | // Offset defines the number of months before the specified endMonth the extraction must be done 307 | func getBoundaries(records [][]string, endMonthStr string, period int, offset int) (startColumn int, endColumn int, startMonth string, endMonth string) { 308 | isWithOffset := (offset != 0) 309 | nbrOfColumns := len(records[0]) 310 | 311 | if strings.ToUpper(endMonthStr) == "LATEST" { 312 | endColumn = nbrOfColumns - 1 313 | } else { 314 | // Search the requested end month. 315 | endColumn = searchStringMonth(records[0], endMonthStr) 316 | //If not found, reset to "latest" 317 | if endColumn == -1 { 318 | fmt.Printf("Warning: %s not found in dataset, reverting to latest available month\n", endMonthStr) 319 | endColumn = nbrOfColumns - 1 320 | } 321 | } 322 | 323 | if isWithOffset { 324 | endColumn = endColumn - offset 325 | if endColumn <= 0 { 326 | fmt.Printf("FATAL: requested offset-ted end period not available.\n") 327 | return 0, 0, "", "" 328 | } 329 | } 330 | 331 | if period >= nbrOfColumns { 332 | period = 0 333 | } 334 | 335 | if period == 0 { 336 | startColumn = 1 337 | } else { 338 | startColumn = (endColumn - period) + 1 339 | } 340 | 341 | startMonth = records[0][startColumn] 342 | endMonth = records[0][endColumn] 343 | 344 | return startColumn, endColumn, startMonth, endMonth 345 | } 346 | 347 | // Searches the loaded records for the request month string 348 | func searchStringMonth(headerRecords []string, endMonthStr string) (endColumn int) { 349 | nbrOfColumns := len(headerRecords) 350 | endColumn = -1 //not found value 351 | for i := 0; i < nbrOfColumns; i++ { 352 | if headerRecords[i] == endMonthStr { 353 | endColumn = i 354 | break 355 | } 356 | } 357 | return endColumn 358 | } 359 | -------------------------------------------------------------------------------- /cmd/extract_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "bytes" 26 | "reflect" 27 | "strings" 28 | "testing" 29 | 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | var records_1 = [][]string{ 34 | {"", "2022-01", "2022-02", "2022-03", "2022-04", "2022-05", "2022-06", "2022-07", "2022-08", "2022-09", "2022-10", "2022-11", "2022-12", "2023-01", "2023-02", "2023-03", "2023-04"}, 35 | {"0x41head", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"}, 36 | {"AScripnic", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"}, 37 | } 38 | 39 | var records_2 = [][]string{ 40 | {"", "2022-01", "2022-02"}, 41 | {"0x41head", "1", "2"}, 42 | {"AScripnic", "1", "2"}, 43 | } 44 | 45 | var resultSlice_1 = [][]string{ 46 | {"Submitter", "Total_PRs"}, 47 | {"0x41head", "78"}, 48 | {"AayushSaini101", "15"}, 49 | {"ChadiEM", "8"}, 50 | {"Artmorse", "6"}, 51 | {"Abingcbc", "5"}, 52 | {"CatherineKiiru", "4"}, 53 | {"AndKiel", "4"}, 54 | {"Absh-Day", "4"}, 55 | {"BorisYaoA", "4"}, 56 | } 57 | 58 | func Test_getBoundaries(t *testing.T) { 59 | 60 | type args struct { 61 | records [][]string 62 | endMonthStr string 63 | months int 64 | offset int 65 | } 66 | tests := []struct { 67 | name string 68 | args args 69 | wantStartColumn int 70 | wantEndColumn int 71 | wantStartMonth string 72 | wantEndMonth string 73 | }{ 74 | { 75 | "Normal case", 76 | args{records: records_1, endMonthStr: "latest", months: 12, offset: 0}, 77 | 5, 16, "2022-05", "2023-04", 78 | }, 79 | { 80 | "Get all available months", 81 | args{records: records_1, endMonthStr: "latest", months: 0, offset: 0}, 82 | 1, 16, "2022-01", "2023-04", 83 | }, 84 | { 85 | "Get more months than available", 86 | args{records: records_1, endMonthStr: "latest", months: 20, offset: 0}, 87 | 1, 16, "2022-01", "2023-04", 88 | }, 89 | { 90 | "Specify end month - normal case", 91 | args{records: records_1, endMonthStr: "2023-02", months: 6, offset: 0}, 92 | 9, 14, "2022-09", "2023-02", 93 | }, 94 | { 95 | "Specify end month - get all available months", 96 | args{records: records_1, endMonthStr: "2023-02", months: 0, offset: 0}, 97 | 1, 14, "2022-01", "2023-02", 98 | }, 99 | { 100 | "Specify end month - get more months than available", 101 | args{records: records_1, endMonthStr: "2023-02", months: 20, offset: 0}, 102 | 1, 14, "2022-01", "2023-02", 103 | }, 104 | { 105 | "Specify end month - end month not found", 106 | args{records: records_1, endMonthStr: "2023-08", months: 12, offset: 0}, 107 | 5, 16, "2022-05", "2023-04", 108 | }, 109 | { 110 | "short month set", 111 | args{records: records_2, endMonthStr: "latest", months: 12, offset: 0}, 112 | 1, 2, "2022-01", "2022-02", 113 | }, 114 | { 115 | "Normal case with offset", 116 | args{records: records_1, endMonthStr: "latest", months: 12, offset: 1}, 117 | 4, 15, "2022-04", "2023-03", 118 | }, 119 | { 120 | "Normal case with endMonth and offset", 121 | args{records: records_1, endMonthStr: "2023-02", months: 6, offset: 1}, 122 | 8, 13, "2022-08", "2023-01", 123 | }, 124 | { 125 | "endMonth and offset out od bound", 126 | args{records: records_1, endMonthStr: "2023-02", months: 6, offset: 16}, 127 | 0, 0, "", "", 128 | }, 129 | { 130 | "offset with latest out of dataset", 131 | args{records: records_1, endMonthStr: "latest", months: 12, offset: 16}, 132 | 0, 0, "", "", 133 | }, 134 | } 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | gotStartColumn, gotEndColumn, gotStartMonth, gotEndMonth := getBoundaries(tt.args.records, tt.args.endMonthStr, tt.args.months, tt.args.offset) 138 | if gotStartColumn != tt.wantStartColumn { 139 | t.Errorf("getBoundaries() gotStartColumn = %v, want %v", gotStartColumn, tt.wantStartColumn) 140 | } 141 | if gotEndColumn != tt.wantEndColumn { 142 | t.Errorf("getBoundaries() gotEndColumn = %v, want %v", gotEndColumn, tt.wantEndColumn) 143 | } 144 | if gotStartMonth != tt.wantStartMonth { 145 | t.Errorf("getBoundaries() gotStartMonth = %v, want %v", gotStartMonth, tt.wantStartMonth) 146 | } 147 | if gotEndMonth != tt.wantEndMonth { 148 | t.Errorf("getBoundaries() gotEndMonth = %v, want %v", gotEndMonth, tt.wantEndMonth) 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func Test_extractData(t *testing.T) { 155 | type args struct { 156 | inputFilename string 157 | topSize int 158 | endMonth string 159 | period int 160 | offset int 161 | inputType InputType 162 | isVerboseExtract bool 163 | } 164 | tests := []struct { 165 | name string 166 | args args 167 | wantResult bool 168 | wantReal_endDate string 169 | wantOutputSlice [][]string 170 | }{ 171 | { 172 | "Happy case", 173 | args{ 174 | inputFilename: "../test_data/short_overview.csv", 175 | topSize: 7, 176 | endMonth: "latest", 177 | period: 12, 178 | inputType: InputTypeSubmitters, 179 | isVerboseExtract: false, 180 | }, 181 | true, "2023-04", resultSlice_1, 182 | }, 183 | } 184 | for _, tt := range tests { 185 | t.Run(tt.name, func(t *testing.T) { 186 | gotResult, gotReal_endDate, gotOutputSlice := extractData(tt.args.inputFilename, tt.args.topSize, tt.args.endMonth, tt.args.period, tt.args.offset, tt.args.inputType, tt.args.isVerboseExtract) 187 | if gotResult != tt.wantResult { 188 | t.Errorf("extractData() gotResult = %v, want %v", gotResult, tt.wantResult) 189 | } 190 | if gotReal_endDate != tt.wantReal_endDate { 191 | t.Errorf("extractData() gotReal_endDate = %v, want %v", gotReal_endDate, tt.wantReal_endDate) 192 | } 193 | if !reflect.DeepEqual(gotOutputSlice, tt.wantOutputSlice) { 194 | t.Errorf("extractData() gotOutputSlice = %v, want %v", gotOutputSlice, tt.wantOutputSlice) 195 | } 196 | }) 197 | } 198 | } 199 | 200 | func Test_ExecuteSubmittersExtractToMarkdown_integrationTest(t *testing.T) { 201 | // Setup test environment 202 | tempDir := t.TempDir() 203 | testOutputFilename := tempDir + "extract_markdown_output.md" 204 | goldenMarkdownFilename, err := duplicateFile("../test_data/extract_reference_output.md", tempDir) 205 | 206 | assert.NoError(t, err, "Unexpected Golden File duplication error") 207 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 208 | 209 | // setup the command line 210 | actual := new(bytes.Buffer) 211 | rootCmd.SetOut(actual) 212 | rootCmd.SetErr(actual) 213 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "--month=latest", "--period=12", "--topSize=35", "--type=submitters", "--history" ,"--out=" + testOutputFilename}) 214 | 215 | // Execute the module under test 216 | error := rootCmd.Execute() 217 | 218 | // Check the results 219 | assert.NoError(t, error, "Unexpected failure") 220 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 221 | } 222 | 223 | func Test_ExecuteCommentersExtractToMarkdown_integrationTest(t *testing.T) { 224 | // Setup test environment 225 | tempDir := t.TempDir() 226 | testOutputFilename := tempDir + "extract_markdown_output.md" 227 | goldenMarkdownFilename, err := duplicateFile("../test_data/extract-commenters_reference_output.md", tempDir) 228 | 229 | assert.NoError(t, err, "Unexpected Golden File duplication error") 230 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 231 | 232 | // setup the command line 233 | actual := new(bytes.Buffer) 234 | rootCmd.SetOut(actual) 235 | rootCmd.SetErr(actual) 236 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "--month=latest", "--period=12", "--topSize=35", "--history=false", "--type=commenters", "--out=" + testOutputFilename}) 237 | 238 | // Execute the module under test 239 | error := rootCmd.Execute() 240 | 241 | // Check the results 242 | assert.NoError(t, error, "Unexpected failure") 243 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 244 | } 245 | 246 | func Test_ExecuteCommentersExtractToMarkdownWithHistory_integrationTest(t *testing.T) { 247 | // Setup test environment 248 | tempDir := t.TempDir() 249 | testOutputFilename := tempDir + "extract_markdown_output.md" 250 | goldenMarkdownFilename, err := duplicateFile("../test_data/extract-commenters-history_reference_output.md", tempDir) 251 | 252 | assert.NoError(t, err, "Unexpected Golden File duplication error") 253 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate Golden File") 254 | 255 | // setup the command line 256 | actual := new(bytes.Buffer) 257 | rootCmd.SetOut(actual) 258 | rootCmd.SetErr(actual) 259 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "--month=latest", "--period=12", "--topSize=35", "--history=true", "--type=commenters", "--out=" + testOutputFilename}) 260 | 261 | // Execute the module under test 262 | error := rootCmd.Execute() 263 | 264 | // Check the results 265 | assert.NoError(t, error, "Unexpected failure") 266 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 267 | //FIXME: check if a history file has been generated 268 | } 269 | 270 | func Test_ExecuteExtractWithUnknownInputType_mustFail(t *testing.T) { 271 | // setup the command line 272 | actual := new(bytes.Buffer) 273 | rootCmd.SetOut(actual) 274 | rootCmd.SetErr(actual) 275 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "--type=blaah"}) 276 | 277 | // Execute the module under test 278 | error := rootCmd.Execute() 279 | 280 | assert.Error(t, error, "Function call should have failed") 281 | 282 | //Error is expected 283 | expectedMsg := "Error: blaah is an invalid input type" 284 | lines := strings.Split(actual.String(), "\n") 285 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 286 | } 287 | 288 | 289 | 290 | func Test_ExecuteExtractWithInvalidOutputDir_mustFail(t *testing.T) { 291 | // setup the command line 292 | actual := new(bytes.Buffer) 293 | rootCmd.SetOut(actual) 294 | rootCmd.SetErr(actual) 295 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "--type=submitters", "--out=./inexistant/directory/output.csv"}) 296 | 297 | // Execute the module under test 298 | error := rootCmd.Execute() 299 | 300 | assert.Error(t, error, "Function call should have failed") 301 | 302 | //Error is expected 303 | expectedMsg := "Error: The directory of specified output file (inexistant/directory) does not exist." 304 | lines := strings.Split(actual.String(), "\n") 305 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 306 | } 307 | 308 | func Test_ExecuteExtractWithNoArgs_mustFail(t *testing.T) { 309 | // setup the command line 310 | actual := new(bytes.Buffer) 311 | rootCmd.SetOut(actual) 312 | rootCmd.SetErr(actual) 313 | rootCmd.SetArgs([]string{"extract"}) 314 | 315 | // Execute the module under test 316 | error := rootCmd.Execute() 317 | 318 | assert.Error(t, error, "Function call should have failed") 319 | 320 | //Error is expected 321 | expectedMsg := "Error: requires at least 1 arg(s), only received 0" 322 | lines := strings.Split(actual.String(), "\n") 323 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 324 | } 325 | 326 | func Test_ExecuteExtractWithInvalidInputFile_mustFail(t *testing.T) { 327 | // setup the command line 328 | actual := new(bytes.Buffer) 329 | rootCmd.SetOut(actual) 330 | rootCmd.SetErr(actual) 331 | rootCmd.SetArgs([]string{"extract", "nonExistantFile.csv"}) 332 | 333 | // Execute the module under test 334 | error := rootCmd.Execute() 335 | 336 | assert.Error(t, error, "Function call should have failed") 337 | 338 | //Error is expected 339 | expectedMsg := "Error: Invalid input file" 340 | lines := strings.Split(actual.String(), "\n") 341 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 342 | } 343 | 344 | func Test_ExecuteExtractWithInvalidEndMonth_mustFail(t *testing.T) { 345 | // setup the command line 346 | actual := new(bytes.Buffer) 347 | rootCmd.SetOut(actual) 348 | rootCmd.SetErr(actual) 349 | rootCmd.SetArgs([]string{"extract", "../test_data/overview.csv", "-m=junkMonth"}) 350 | 351 | // Execute the module under test 352 | error := rootCmd.Execute() 353 | 354 | assert.Error(t, error, "Function call should have failed") 355 | 356 | //Error is expected 357 | expectedMsg := "Error: \"junkMonth\" is an invalid month" 358 | lines := strings.Split(actual.String(), "\n") 359 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 360 | } 361 | 362 | //TODO: integration test for CSV output 363 | -------------------------------------------------------------------------------- /cmd/utilities_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Jean-Marc Meessen jean-marc@meessen-web.org 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "bufio" 26 | "fmt" 27 | "io" 28 | "os" 29 | "path/filepath" 30 | "reflect" 31 | "testing" 32 | 33 | "github.com/stretchr/testify/assert" 34 | ) 35 | 36 | func Test_isFileValid(t *testing.T) { 37 | type args struct { 38 | fileName string 39 | } 40 | tests := []struct { 41 | name string 42 | args args 43 | want bool 44 | }{ 45 | { 46 | "Happy case", 47 | args{"../test_data/not_a_csv.txt"}, 48 | true, 49 | }, 50 | { 51 | "File does not exist", 52 | args{"unexistantFile.txt"}, 53 | false, 54 | }, 55 | { 56 | "File is a directory in fact", 57 | args{"../test_data"}, 58 | false, 59 | }, 60 | } 61 | 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | if got := isFileValid(tt.args.fileName); got != tt.want { 65 | t.Errorf("isFileValid() = %v, want %v", got, tt.want) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | func Test_validateMonth(t *testing.T) { 72 | type args struct { 73 | month string 74 | isVerbose bool 75 | } 76 | tests := []struct { 77 | name string 78 | args args 79 | want bool 80 | }{ 81 | { 82 | "lowercase latest", 83 | args{"latest", true}, 84 | true, 85 | }, 86 | { 87 | "uppercase latest", 88 | args{"LATEST", true}, 89 | true, 90 | }, 91 | { 92 | "empty month", 93 | args{"", true}, 94 | false, 95 | }, 96 | { 97 | "happy case 1", 98 | args{"2023-08", true}, 99 | true, 100 | }, 101 | { 102 | "happy case 2", 103 | args{"2013-08", true}, 104 | true, 105 | }, 106 | { 107 | "happy case 3", 108 | args{"2023-12", true}, 109 | true, 110 | }, 111 | { 112 | "happy case 4", 113 | args{"2020-12", true}, 114 | true, 115 | }, 116 | { 117 | "invalid month 1", 118 | args{"2023-13", true}, 119 | false, 120 | }, 121 | { 122 | "invalid month 2", 123 | args{"2023-00", true}, 124 | false, 125 | }, 126 | { 127 | "invalid year (too old)", 128 | args{"2003-08", true}, 129 | false, 130 | }, 131 | { 132 | "plain junk 1", 133 | args{"2023", true}, 134 | false, 135 | }, 136 | { 137 | "plain junk 2", 138 | args{"blaah", true}, 139 | false, 140 | }, 141 | } 142 | for _, tt := range tests { 143 | t.Run(tt.name, func(t *testing.T) { 144 | if got := isValidMonth(tt.args.month, tt.args.isVerbose); got != tt.want { 145 | t.Errorf("validateMonth() = %v, want %v", got, tt.want) 146 | } 147 | }) 148 | } 149 | } 150 | 151 | func Test_isWithMDfileExtension(t *testing.T) { 152 | type args struct { 153 | filename string 154 | } 155 | tests := []struct { 156 | name string 157 | args args 158 | want bool 159 | }{ 160 | { 161 | "Markdown extension", 162 | args{filename: "myfile.md"}, 163 | true, 164 | }, 165 | { 166 | "Markdown extension (mixed case)", 167 | args{filename: "myfile.mD"}, 168 | true, 169 | }, 170 | { 171 | "CSV extension", 172 | args{filename: "myfile.csv"}, 173 | false, 174 | }, 175 | { 176 | "no extension", 177 | args{filename: "myfile"}, 178 | false, 179 | }, 180 | { 181 | "just the dot", 182 | args{filename: "myfile."}, 183 | false, 184 | }, 185 | } 186 | for _, tt := range tests { 187 | t.Run(tt.name, func(t *testing.T) { 188 | if got := isWithMDfileExtension(tt.args.filename); got != tt.want { 189 | t.Errorf("isWithMDfileExtension() = %v, want %v", got, tt.want) 190 | } 191 | }) 192 | } 193 | } 194 | 195 | func Test_writeMarkdownFile(t *testing.T) { 196 | // Setup environment 197 | tempDir := t.TempDir() 198 | goldenMarkdownFilename, err := duplicateFile("../test_data/Reference_extract_output.md", tempDir) 199 | 200 | assert.NoError(t, err, "Unexpected File duplication error") 201 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate test file") 202 | 203 | // Setup input data 204 | testOutputFilename := tempDir + "markdown_output.md" 205 | introductionText := "# Extract\n" 206 | data := [][]string{ 207 | {"Submitter", "Total_PRs"}, 208 | {"basil", "1245"}, 209 | {"MarkEWaite", "1150"}, 210 | {"lemeurherve", "939"}, 211 | {"NotMyFault", "926"}, 212 | {"dduportal", "859"}, 213 | {"jonesbusy", "415"}, 214 | {"jglick", "378"}, 215 | {"smerle33", "353"}, 216 | {"timja", "250"}, 217 | {"uhafner", "215"}, 218 | {"gounthar", "208"}, 219 | {"mawinter69", "179"}, 220 | {"daniel-beck", "164"}} 221 | 222 | // Execute function under test 223 | isHistory := false 224 | writeDataAsMarkdown(testOutputFilename, data, introductionText, isHistory, InputTypeSubmitters) 225 | 226 | // result validation 227 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 228 | } 229 | 230 | func Test_writeMarkdownFile_withLinks(t *testing.T) { 231 | // Setup environment 232 | tempDir := t.TempDir() 233 | goldenMarkdownFilename, err := duplicateFile("../test_data/Reference_extract_output_with_links.md", tempDir) 234 | 235 | assert.NoError(t, err, "Unexpected File duplication error") 236 | assert.NotEmpty(t, goldenMarkdownFilename, "Failure to duplicate test file") 237 | 238 | // Setup input data 239 | testOutputFilename := tempDir + "markdown_output.md" 240 | introductionText := "# Extract\n" 241 | data := [][]string{ 242 | {"Submitter", "Total_PRs"}, 243 | {"basil", "1245"}, 244 | {"MarkEWaite", "1150"}, 245 | {"lemeurherve", "939"}, 246 | {"NotMyFault", "926"}, 247 | {"dduportal", "859"}, 248 | {"jonesbusy", "415"}, 249 | {"jglick", "378"}, 250 | {"smerle33", "353"}, 251 | {"timja", "250"}, 252 | {"uhafner", "215"}, 253 | {"gounthar", "208"}, 254 | {"mawinter69", "179"}, 255 | {"daniel-beck", "164"}} 256 | 257 | // Execute function under test 258 | isHistory := true 259 | writeDataAsMarkdown(testOutputFilename, data, introductionText, isHistory, InputTypeSubmitters) 260 | 261 | // result validation 262 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenMarkdownFilename)) 263 | } 264 | 265 | func Test_writeHistoryOutput(t *testing.T) { 266 | // Setup environment 267 | inputPivotTableName := "../test_data/overview.csv" 268 | tempDir := t.TempDir() 269 | goldenHistoryFilename, err := duplicateFile("../test_data/historicExtract_reference.csv", tempDir) 270 | 271 | assert.NoError(t, err, "Unexpected File duplication error") 272 | assert.NotEmpty(t, goldenHistoryFilename, "Failure to duplicate test file") 273 | 274 | // Setup input data 275 | testOutputFilename := tempDir + "/history_output.csv" 276 | data := [][]string{ 277 | {"Submitter", "Total_PRs"}, 278 | {"basil", "1245"}, 279 | {"MarkEWaite", "1150"}, 280 | {"lemeurherve", "939"}, 281 | {"NotMyFault", "926"}, 282 | {"dduportal", "859"}, 283 | {"jonesbusy", "415"}, 284 | {"jglick", "378"}, 285 | {"smerle33", "353"}, 286 | {"timja", "250"}, 287 | {"uhafner", "215"}, 288 | {"gounthar", "208"}, 289 | {"mawinter69", "179"}, 290 | {"daniel-beck", "164"}} 291 | 292 | // Execute function under test 293 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 294 | assert.NoError(t, writeErr, "Function under test returned an unexpected error") 295 | 296 | // *** result validation *** 297 | 298 | //check existence of plot files 299 | for i, data_row := range data { 300 | if i != 0 { 301 | outputedPng := filepath.Join(tempDir, "plot", data_row[0]+".png") 302 | assert.FileExistsf(t, outputedPng, "did not find the expected plot %s\n", outputedPng) 303 | } 304 | } 305 | 306 | // Check the output file 307 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenHistoryFilename)) 308 | } 309 | 310 | func Test_writeHistoryOutput_compare(t *testing.T) { 311 | // Setup environment 312 | inputPivotTableName := "../test_data/overview.csv" 313 | tempDir := t.TempDir() 314 | goldenHistoryFilename, err := duplicateFile("../test_data/historicCompare_reference.csv", tempDir) 315 | 316 | assert.NoError(t, err, "Unexpected File duplication error") 317 | assert.NotEmpty(t, goldenHistoryFilename, "Failure to duplicate test file") 318 | 319 | // Setup input data 320 | testOutputFilename := tempDir + "/history_output.csv" 321 | data := [][]string{ 322 | {"Submitter", "Total_PRs", "status"}, 323 | {"basil", "1245", ""}, 324 | {"MarkEWaite", "1150", ""}, 325 | {"lemeurherve", "939", ""}, 326 | {"NotMyFault", "926", ""}, 327 | {"dduportal", "859", ""}, 328 | {"jonesbusy", "415", ""}, 329 | {"jglick", "378", ""}, 330 | {"smerle33", "353", ""}, 331 | {"timja", "250", "churned", ""}, 332 | {"uhafner", "215", ""}, 333 | {"gounthar", "208", "new", ""}, 334 | {"mawinter69", "179", ""}, 335 | {"daniel-beck", "164", ""}} 336 | 337 | // Execute function under test 338 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 339 | assert.NoError(t, writeErr, "Function under test returned an unexpected error") 340 | 341 | // *** result validation *** 342 | 343 | //check existence of plot files 344 | for i, data_row := range data { 345 | if i != 0 { 346 | outputedPng := filepath.Join(tempDir, "plot", data_row[0]+".png") 347 | assert.FileExistsf(t, outputedPng, "did not find the expected plot %s\n", outputedPng) 348 | } 349 | } 350 | 351 | // Check the output file 352 | assert.NoError(t, isFileEquivalent(testOutputFilename, goldenHistoryFilename)) 353 | } 354 | 355 | func Test_writeHistoryOutput_notFoundUser(t *testing.T) { 356 | // Setup environment 357 | inputPivotTableName := "../test_data/overview.csv" 358 | tempDir := t.TempDir() 359 | 360 | // Setup input data 361 | testOutputFilename := tempDir + "/history_output.csv" 362 | data := [][]string{ 363 | {"Submitter", "Total_PRs"}, 364 | {"basil", "1245"}, 365 | {"MarkEWaite", "1150"}, 366 | {"lemeurherve", "939"}, 367 | {"NotMyFault", "926"}, 368 | {"dduportal", "859"}, 369 | {"jonesbusy", "415"}, 370 | {"jglick", "378"}, 371 | {"smerle33", "353"}, 372 | {"timja", "250"}, 373 | {"uhafner", "215"}, 374 | {"unknownUser", "208"}, 375 | {"mawinter69", "179"}, 376 | {"daniel-beck", "164"}} 377 | 378 | // Execute function under test 379 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 380 | 381 | assert.EqualErrorf(t, writeErr, "Supplied name (unknownUser) was not found in input pivot table file", "Function under test should have failed") 382 | 383 | } 384 | 385 | func Test_writeHistoryOutput_noTopUserData(t *testing.T) { 386 | // Setup environment 387 | inputPivotTableName := "../test_data/overview.csv" 388 | tempDir := t.TempDir() 389 | 390 | // Setup input data 391 | testOutputFilename := tempDir + "/history_output.csv" 392 | data := [][]string{ 393 | {"Submitter", "Total_PRs"}, 394 | } 395 | 396 | // Execute function under test 397 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 398 | 399 | assert.EqualErrorf(t, writeErr, "The generated top user data seems empty.", "Function under test should have failed") 400 | } 401 | 402 | func Test_writeHistoryOutput_noPivotTableData(t *testing.T) { 403 | // Setup environment 404 | inputPivotTableName := "../test_data/noData_overview.csv" 405 | tempDir := t.TempDir() 406 | 407 | // Setup input data 408 | testOutputFilename := tempDir + "/history_output.csv" 409 | data := [][]string{ 410 | {"Submitter", "Total_PRs"}, 411 | {"basil", "1245"}, 412 | {"MarkEWaite", "1150"}, 413 | {"lemeurherve", "939"}, 414 | {"NotMyFault", "926"}, 415 | {"dduportal", "859"}, 416 | {"jonesbusy", "415"}, 417 | {"jglick", "378"}, 418 | {"smerle33", "353"}, 419 | {"timja", "250"}, 420 | {"uhafner", "215"}, 421 | {"unknownUser", "208"}, 422 | {"mawinter69", "179"}, 423 | {"daniel-beck", "164"}} 424 | 425 | // Execute function under test 426 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 427 | 428 | assert.EqualErrorf(t, writeErr, "The pivot table (../test_data/noData_overview.csv) seems empty.", "Function under test should have failed") 429 | } 430 | 431 | func Test_writeHistoryOutput_compareWithWrongHeader(t *testing.T) { 432 | // Setup environment 433 | inputPivotTableName := "../test_data/overview.csv" 434 | tempDir := t.TempDir() 435 | 436 | // Setup input data 437 | testOutputFilename := tempDir + "/history_output.csv" 438 | data := [][]string{ 439 | {"Submitter", "Total_PRs", "junkHeader"}, 440 | {"basil", "1245", ""}, 441 | {"MarkEWaite", "1150", ""}, 442 | {"lemeurherve", "939", ""}, 443 | {"NotMyFault", "926", ""}, 444 | {"dduportal", "859", ""}, 445 | {"jonesbusy", "415", ""}, 446 | {"jglick", "378", ""}, 447 | {"smerle33", "353", ""}, 448 | {"timja", "250", "churned", ""}, 449 | {"uhafner", "215", ""}, 450 | {"gounthar", "208", "new", ""}, 451 | {"mawinter69", "179", ""}, 452 | {"daniel-beck", "164", ""}} 453 | 454 | // Execute function under test 455 | writeErr := writeHistoryOutput(testOutputFilename, inputPivotTableName, InputTypeSubmitters, data) 456 | 457 | expectedErrorMessage := "COMPARE output check failure: found three columns but third one doesn't have the expected title (found \"junkHeader\" instead of \"status\")" 458 | assert.EqualErrorf(t, writeErr, expectedErrorMessage, "Function under test should have failed") 459 | } 460 | 461 | func Test_getIndexInPivotTable(t *testing.T) { 462 | testInputSlice := [][]string{ 463 | {"", "month_1", "month_2", "month_3"}, 464 | {"basil", "1245", "1", "2"}, 465 | {"MarkEWaite", "1150", "1", "2"}, 466 | {"lemeurherve", "939", "1", "2"}, 467 | {"NotMyFault", "926", "1", "2"}, 468 | {"dduportal", "859", "1", "2"}, 469 | {"jonesbusy", "415", "1", "2"}, 470 | {"jglick", "378", "1", "2"}, 471 | {"smerle33", "353", "1", "2"}, 472 | {"timja", "250", "1", "2"}, 473 | {"uhafner", "215", "1", "2"}, 474 | {"gounthar", "208", "1", "2"}, 475 | {"mawinter69", "179", "1", "2"}, 476 | {"daniel-beck", "164", "1", "2"}} 477 | 478 | type args struct { 479 | pivotRecords [][]string 480 | name string 481 | } 482 | tests := []struct { 483 | name string 484 | args args 485 | wantIndex int 486 | }{ 487 | { 488 | "Happy case - 1", 489 | args{pivotRecords: testInputSlice, name: "basil"}, 490 | 1, 491 | }, 492 | { 493 | "Happy case - 2", 494 | args{pivotRecords: testInputSlice, name: "uhafner"}, 495 | 10, 496 | }, 497 | { 498 | "Happy case - 3", 499 | args{pivotRecords: testInputSlice, name: "daniel-beck"}, 500 | 13, 501 | }, 502 | { 503 | "notfound", 504 | args{pivotRecords: testInputSlice, name: "jmm"}, 505 | -1, 506 | }, 507 | } 508 | for _, tt := range tests { 509 | t.Run(tt.name, func(t *testing.T) { 510 | if gotIndex := getIndexInPivotTable(tt.args.pivotRecords, tt.args.name); gotIndex != tt.wantIndex { 511 | t.Errorf("getIndexInPivotTable() = %v, want %v", gotIndex, tt.wantIndex) 512 | } 513 | }) 514 | } 515 | } 516 | func Test_CheckDir(t *testing.T) { 517 | type args struct { 518 | file string 519 | } 520 | tests := []struct { 521 | name string 522 | args args 523 | wantErr bool 524 | }{ 525 | { 526 | "Valid directory", 527 | args{file: "../test_data/fle-1.txt"}, 528 | false, 529 | }, 530 | { 531 | "Invalid directory", 532 | args{file: "../junkDir/fle-1.txt"}, 533 | true, 534 | }, 535 | } 536 | for _, tt := range tests { 537 | t.Run(tt.name, func(t *testing.T) { 538 | if err := CheckDir(tt.args.file); (err != nil) != tt.wantErr { 539 | t.Errorf("CheckDir() error = %v, wantErr %v", err, tt.wantErr) 540 | } 541 | }) 542 | } 543 | } 544 | 545 | func Test_generateHistoryFilename(t *testing.T) { 546 | type args struct { 547 | outputFilename string 548 | dataType InputType 549 | isCompare bool 550 | } 551 | tests := []struct { 552 | name string 553 | args args 554 | wantHistoryFilename string 555 | }{ 556 | { 557 | "Happy case - submitters", 558 | args{ 559 | outputFilename: "output.md", 560 | dataType: InputTypeSubmitters, 561 | isCompare: false, 562 | }, 563 | "./top_submitters_fullHistory.csv", 564 | }, 565 | { 566 | "Happy case - submitters - evolution", 567 | args{ 568 | outputFilename: "output.md", 569 | dataType: InputTypeSubmitters, 570 | isCompare: true, 571 | }, 572 | "./top_submitters_evolution_fullHistory.csv", 573 | }, 574 | { 575 | "Happy case - commenters - evolution - with path", 576 | args{ 577 | outputFilename: "consolidated_data/output.md", 578 | dataType: InputTypeCommenters, 579 | isCompare: true, 580 | }, 581 | "consolidated_data/top_commenters_evolution_fullHistory.csv", 582 | }, 583 | } 584 | for _, tt := range tests { 585 | t.Run(tt.name, func(t *testing.T) { 586 | gotHistoryFilename := generateHistoryFilename(tt.args.outputFilename, tt.args.dataType, tt.args.isCompare) 587 | if gotHistoryFilename != tt.wantHistoryFilename { 588 | t.Errorf("generateHistoryFilename() = %v, want %v", gotHistoryFilename, tt.wantHistoryFilename) 589 | } 590 | }) 591 | } 592 | } 593 | 594 | // ------------------------------ 595 | // 596 | // Test Utilities 597 | // 598 | // ------------------------------ 599 | 600 | // duplicate test file as a temporary file. 601 | // The temporary directory should be created in the calling test so that it gets cleaned at test completion. 602 | func duplicateFile(originalFileName, targetDir string) (tempFileName string, err error) { 603 | 604 | //Check the status and size of the original file 605 | sourceFileStat, err := os.Stat(originalFileName) 606 | if err != nil { 607 | return "", err 608 | } 609 | if !sourceFileStat.Mode().IsRegular() { 610 | return "", fmt.Errorf("%s is not a regular file", originalFileName) 611 | } 612 | sourceFileSize := sourceFileStat.Size() 613 | 614 | //Open the original file 615 | source, err := os.Open(originalFileName) 616 | if err != nil { 617 | return "", err 618 | } 619 | defer source.Close() 620 | 621 | //Get the original file's extension 622 | originalFileExtension := filepath.Ext(originalFileName) 623 | 624 | // generate temporary file name in temp directory 625 | file, err := os.CreateTemp(targetDir, "testData.*"+originalFileExtension) 626 | if err != nil { 627 | return "", err 628 | } 629 | tempFileName = file.Name() 630 | 631 | // create the new file duplication 632 | destination, err := os.Create(tempFileName) 633 | if err != nil { 634 | return "", err 635 | } 636 | defer destination.Close() 637 | 638 | // Do the actual copy 639 | bytesCopied, err := io.Copy(destination, source) 640 | if err != nil { 641 | return tempFileName, err 642 | } 643 | if bytesCopied != sourceFileSize { 644 | return tempFileName, fmt.Errorf("Source and destination file size do not match after copy (%s is %d bytes and %s is %d bytes", originalFileName, sourceFileSize, tempFileName, bytesCopied) 645 | } 646 | 647 | // All went well 648 | return tempFileName, nil 649 | } 650 | 651 | func isFileEquivalent(tempFileName, goldenFileName string) error { 652 | 653 | // Is the size the same 654 | tempFileSize := getFileSize(tempFileName) 655 | goldenFileSize := getFileSize(goldenFileName) 656 | 657 | if tempFileSize == 0 { 658 | return fmt.Errorf("%s is 0 byte long", tempFileName) 659 | } 660 | 661 | if goldenFileSize == 0 { 662 | return fmt.Errorf("%s is 0 byte long", goldenFileName) 663 | } 664 | 665 | // We don't throw an error immediately to give a better hint where the files diverge 666 | fileDifference := fmt.Sprintf("Files have same sizes (%d bytes)", tempFileSize) 667 | if tempFileSize != goldenFileSize { 668 | fileDifference = fmt.Sprintf("Files are of different sizes: found %d bytes while expecting reference %d bytes \n", tempFileSize, goldenFileSize) 669 | } 670 | 671 | // load both files 672 | err, tempFile_List := loadFileToTest(tempFileName) 673 | if err != nil { 674 | return fmt.Errorf("Unexpected error loading %s : %v \n", tempFileName, err) 675 | } 676 | 677 | err, goldenFile_List := loadFileToTest(goldenFileName) 678 | if err != nil { 679 | return fmt.Errorf("Unexpected error loading %s : %v \n", goldenFileName, err) 680 | } 681 | 682 | //Compare the two lists 683 | for index, line := range tempFile_List { 684 | if line != goldenFile_List[index] { 685 | return fmt.Errorf("%s\nCompare failure: line %d do not match\nGenerated file: [%s]\nGolden file: [%s]\n", fileDifference, index, line, goldenFile_List[index]) 686 | } 687 | } 688 | 689 | //If we reached this, we are all good 690 | return nil 691 | } 692 | 693 | // load input file 694 | func loadFileToTest(fileName string) (error, []string) { 695 | 696 | f, err := os.Open(fileName) 697 | if err != nil { 698 | return fmt.Errorf("Unable to read input file %s: %v\n", fileName, err), nil 699 | } 700 | defer f.Close() 701 | 702 | var loadedFile []string 703 | 704 | scanner := bufio.NewScanner(f) 705 | for scanner.Scan() { 706 | loadedFile = append(loadedFile, scanner.Text()) 707 | } 708 | 709 | if err := scanner.Err(); err != nil { 710 | return fmt.Errorf("Error loading \"%s\": %v", fileName, err), nil 711 | } 712 | 713 | if len(loadedFile) <= 1 { 714 | return fmt.Errorf("Error: \"%s\" seems empty. Retrieved %d lines.", fileName, len(loadedFile)), nil 715 | } 716 | 717 | return nil, loadedFile 718 | } 719 | 720 | // Gets the size of a file 721 | func getFileSize(fileName string) int64 { 722 | tempFileStat, err := os.Stat(fileName) 723 | if err != nil { 724 | fmt.Printf("Unexpected error getting details of %s: %v\n", fileName, err) 725 | return 0 726 | } 727 | if !tempFileStat.Mode().IsRegular() { 728 | fmt.Printf("%s is not a regular file\n", fileName) 729 | return 0 730 | } 731 | return tempFileStat.Size() 732 | } 733 | 734 | func Test_get_columnsWidth(t *testing.T) { 735 | type args struct { 736 | output_data_slice [][]string 737 | } 738 | tests := []struct { 739 | name string 740 | args args 741 | want []int 742 | wantErr bool 743 | }{ 744 | { 745 | "Happy case", 746 | args{ 747 | [][]string{ 748 | {"aaa aaa", "12", "ccccccc"}, 749 | {"aaa aaa aa", "124", "cccccccccc"}, 750 | {"aaa", "12", "cccccccccc"}, 751 | {"aaa", "1024", "cccccccccccc"}, 752 | }, 753 | }, 754 | []int{10, 4, 12}, 755 | false, 756 | }, 757 | { 758 | "empty field", 759 | args{ 760 | [][]string{ 761 | {"aaa aaa", "12", ""}, 762 | {"aaa aaa aa", "124", "cccccccccc"}, 763 | {"aaa", "12", "cccccccccc"}, 764 | {"aaa", "1024", "cccccccccccc"}, 765 | }, 766 | }, 767 | []int{10, 4, 12}, 768 | false, 769 | }, 770 | { 771 | "Column number mismatch", 772 | args{ 773 | [][]string{ 774 | {"aaa aaa", "12", ""}, 775 | {"aaa aaa aa", "124"}, 776 | {"aaa", "12", "cccccccccc"}, 777 | {"aaa", "1024", "cccccccccccc"}, 778 | }, 779 | }, 780 | nil, 781 | true, 782 | }, 783 | } 784 | for _, tt := range tests { 785 | t.Run(tt.name, func(t *testing.T) { 786 | got, err := get_columnsWidth(tt.args.output_data_slice) 787 | if (err != nil) != tt.wantErr { 788 | t.Errorf("get_columnsWidth() error = %v, wantErr %v", err, tt.wantErr) 789 | return 790 | } 791 | if !reflect.DeepEqual(got, tt.want) { 792 | t.Errorf("get_columnsWidth() = %v, want %v", got, tt.want) 793 | } 794 | }) 795 | } 796 | } 797 | --------------------------------------------------------------------------------