├── 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 | 
5 | [](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 |
--------------------------------------------------------------------------------