├── test-data ├── exclusions.txt ├── test-exclusion.txt ├── empty-submission-list.csv ├── oneLine-submission-list.csv ├── small-submission-list.csv ├── pr_per_submitter-2024-04.csv └── pr_per_submitter-2024-03.csv ├── CODEOWNERS ├── loadToken.sh ├── CONTRIBUTING.md ├── get-comments.sh ├── Notes ├── Retrieving_token_from_1Pass.md ├── random_notes.md ├── CLI description.md └── debug notes.md ├── README.md ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── LICENSE ├── go.mod ├── jenkins-contribution-extractor.go ├── Makefile ├── cmd ├── logging.go ├── get.go ├── quota_test.go ├── version.go ├── root_test.go ├── get-commenters_test.go ├── root.go ├── get-submitters_test.go ├── exclusions.go ├── exclusions_test.go ├── honor_test.go ├── quota.go ├── get-commenters.go ├── remove.go ├── get-commenters-forOnePr.go ├── get-commenters-forOnePr_test.go ├── utilities.go ├── honor.go ├── remove_test.go ├── get-submitters.go └── utilities_test.go ├── .goreleaser.yml └── go.sum /test-data/exclusions.txt: -------------------------------------------------------------------------------- 1 | # this is an exclusion file comment 2 | user1 3 | user2 # This is an inline comment -------------------------------------------------------------------------------- /test-data/test-exclusion.txt: -------------------------------------------------------------------------------- 1 | # Exclusion file for test purposes 2 | 3 | basil 4 | MarkEWaite 5 | sghill-rewrite -------------------------------------------------------------------------------- /test-data/empty-submission-list.csv: -------------------------------------------------------------------------------- 1 | org,repository,number,url,state,created_at,merged_at,user.login,month_year,title 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | * @jenkins-infra/contribution-stats 3 | -------------------------------------------------------------------------------- /loadToken.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Do not forget to source the file 3 | export GITHUB_TOKEN=$(op read "op://Dev - hacking/Jenkins_stats_PAT token/token") -------------------------------------------------------------------------------- /test-data/oneLine-submission-list.csv: -------------------------------------------------------------------------------- 1 | org,repository,number,url,state,created_at,merged_at,user.login,month_year,title 2 | "jenkinsci","embeddable-build-status-plugin",229,"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229","closed","2023-08-11T21:18:19Z","2023-08-12T03:55:01Z","MarkEWaite","2023-08","Test with Java 21" 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Releasing 2 | 3 | - tag the main branch with `git tag v0.2.14 -m "V 0.2.14"` 4 | - push the branch to GitHub `git push origin --tags` 5 | 6 | Verify on github the release action that the delivery to HomeBrew worked. 7 | 8 | ### to retry releasing 9 | - delete release on GitHub 10 | - delete tag locally with `git tag -d v0.2.14` 11 | - delete the tag on the remote with `git push --delete origin v0.2.14` -------------------------------------------------------------------------------- /get-comments.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | ./jenkins-get-commenters quota 5 | ./jenkins-get-commenters test-data/submissions-2023-08.csv -a 6 | ./jenkins-get-commenters quota 7 | ./jenkins-get-commenters test-data/submissions-2023-07.csv -a 8 | ./jenkins-get-commenters quota 9 | ./jenkins-get-commenters test-data/submissions-2023-06.csv -a 10 | ./jenkins-get-commenters quota 11 | ./jenkins-get-commenters test-data/submissions-2023-05.csv -a 12 | ./jenkins-get-commenters quota 13 | ./jenkins-get-commenters test-data/submissions-2023-04.csv -a 14 | ./jenkins-get-commenters quota 15 | -------------------------------------------------------------------------------- /Notes/Retrieving_token_from_1Pass.md: -------------------------------------------------------------------------------- 1 | # How to setup and retrieve Github Token from 1Pass 2 | 3 | Pre-requisite 4 | - see [Get started with 1Password CLI](https://developer.1password.com/docs/cli/get-started/#step-1-install-1password-cli) 5 | - 1Pass CLI client is installed (`brew install 1password-cli`) 6 | - 1Pass CLI client is allowed to access database 7 | 8 | 9 | ## create token in GitHub 10 | - see notes in Obsidian 11 | - add it in 1Pass 12 | 13 | ## Load token in environment var 14 | 15 | ```sh 16 | export GITHUB_TOKEN=$(op read "op://Dev - hacking/Jenkins_stats_PAT token/token") 17 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jenkins-contribution-extractor 2 | Retrieves Jenkins Community related data points that help to evaluate community health. 3 | 4 | ![Go Build & Test](https://github.com/jenkins-infra/jenkins-contribution-extractor/actions/workflows/ci.yml/badge.svg) 5 | [![codecov](https://codecov.io/gh/jenkins-infra/jenkins-contribution-extractor/graph/badge.svg?token=60VURFO5A6)](https://codecov.io/gh/jenkins-infra/jenkins-contribution-extractor) 6 | 7 | ## installation 8 | For MacOS users, `homebrew` is the easiest installation method. 9 | 10 | - add the Homebrew tap with `brew tap jenkins-infra/tap`. 11 | - install the application with `brew install jenkins-contribution-extractor`. 12 | -------------------------------------------------------------------------------- /.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 | jenkins_commenters_data.csv 28 | honored_contributor.csv 29 | 30 | #Goreleaser output directory 31 | dist/ 32 | 33 | #exclude temporary executables 34 | jenkins-contribution-extractor 35 | 36 | #We don't want to keep debug traces 37 | debug.log 38 | -------------------------------------------------------------------------------- /.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@v4 14 | - name: Unshallow Fetch 15 | run: git fetch --prune --unshallow 16 | - uses: actions/setup-go@v4 17 | with: 18 | go-version: '^1.23.2' 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@v5 26 | with: 27 | distribution: goreleaser 28 | args: release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | HOMEBREW: ${{ steps.generate_homebrew_token.outputs.token }} 32 | 33 | -------------------------------------------------------------------------------- /Notes/random_notes.md: -------------------------------------------------------------------------------- 1 | # Random notes 2 | 3 | ## Creating a Cobra skeleton 4 | 5 | ```sh 6 | go mod init github.com/jenkins-infra/jenkins-get-commenters 7 | cobra-cli init --author "Jean-Marc Meessen jean-marc@meessen-web.org" --license MIT 8 | cobra-cli add get --author "Jean-Marc Meessen jean-marc@meessen-web.org" --license MIT 9 | ``` 10 | 11 | ## hide flags in Cobra (debug) 12 | - https://stackoverflow.com/questions/46591225/how-to-mark-some-global-persistent-flags-as-hidden-for-some-cobra-commands 13 | 14 | ## Rate limits handling 15 | - https://docs.github.com/en/rest/guides/best-practices-for-using-the-rest-api?apiVersion=2022-11-28 16 | 17 | ``` 18 | Nbr of PR without comments: 446 19 | Nbr of PR with comments: 638 20 | Total comments: 2163 21 | ➜ jenkins-get-commenters git:(quota) ✗ ./jenkins-get-commenters quota 22 | Limit: 5000 23 | Remaining 4068 24 | ➜ jenkins-get-commenters git:(quota) ✗ 25 | ``` 26 | 27 | https://umarcor.github.io/cobra/#generating-markdown-docs-for-your-own-cobracommand -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Notes/CLI description.md: -------------------------------------------------------------------------------- 1 | # notes on the CLI 2 | 3 | ## Verbs 4 | 5 | - **none** 6 | - used to read a previously generated list of PRs with the first three fields being org, project, PR_nbr 7 | - first (and required) parameter is the input filename 8 | - define output file `-o or --output` 9 | - append or overwrite output 10 | - define env_var containing the Github token 11 | - generated CSV with or without header. 12 | - verbose processing 13 | 14 | - **get** 15 | - retrieves the comment count for a given org - project - PR combination 16 | - required params: 17 | - `--org` : organization 18 | - `--project` : project 19 | - `--pr` : Pull Request Reference 20 | - `--full_ref` : combination of above as "org/project/pr" 21 | 22 | ## New interface 23 | 24 | ### **`jenkins-contribution-extractor`** 25 | - **root** (displays help) 26 | - **version** 27 | - -d : detailed 28 | - **quota** 29 | - **get** 30 | - **commenters** 31 | -- debug 32 | - **for_pr** \[pr_spec\] 33 | --debug 34 | - **pr** 35 | org 36 | mois 37 | - **test** -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jenkins-infra/jenkins-contribution-extractor 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require golang.org/x/oauth2 v0.24.0 8 | 9 | require ( 10 | github.com/davecgh/go-spew v1.1.1 // indirect 11 | github.com/kr/pretty v0.3.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 14 | ) 15 | 16 | require ( 17 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 18 | github.com/cloudflare/circl v1.5.0 // indirect 19 | github.com/google/go-github/v55 v55.0.0 20 | github.com/google/go-querystring v1.1.0 // indirect 21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 23 | github.com/rivo/uniseg v0.4.7 // indirect 24 | github.com/schollz/progressbar/v3 v3.17.1 25 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 26 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect 27 | github.com/spf13/cobra v1.8.1 28 | github.com/spf13/pflag v1.0.5 // indirect 29 | github.com/stretchr/testify v1.10.0 30 | golang.org/x/crypto v0.35.0 // indirect 31 | golang.org/x/sys v0.30.0 // indirect 32 | golang.org/x/term v0.29.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /jenkins-contribution-extractor.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-extractor/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | -------------------------------------------------------------------------------- /test-data/small-submission-list.csv: -------------------------------------------------------------------------------- 1 | org,repository,number,url,state,created_at,merged_at,user.login,month_year,title 2 | "jenkinsci","embeddable-build-status-plugin",229,"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229","closed","2023-08-11T21:18:19Z","2023-08-12T03:55:01Z","MarkEWaite","2023-08","Test with Java 21" 3 | "jenkinsci","ldap-plugin",248,"https://github.com/jenkinsci/ldap-plugin/pull/248","closed","2023-08-12T12:09:11Z","2023-09-22T16:21:31Z","NotMyFault","2023-08","Test on Java 21" 4 | "jenkinsci","ecu-test-execution-plugin",54,"https://github.com/jenkinsci/ecu-test-execution-plugin/pull/54","closed","2023-08-07T10:06:24Z","2023-09-22T09:03:34Z","MxEh-TT","2023-08","inital package check implementation (#53)" 5 | "jenkinsci","build-blocker-plugin",19,"https://github.com/jenkinsci/build-blocker-plugin/pull/19","closed","2023-08-07T06:35:02Z","2023-09-18T13:42:06Z","olamy","2023-08","add @Symbol to be able to easily use the plugin in a declarative pipeline" 6 | "jenkinsci","credentials-plugin",475,"https://github.com/jenkinsci/credentials-plugin/pull/475","closed","2023-08-12T08:16:01Z","2023-09-21T16:16:52Z","NotMyFault","2023-08","Test on Java 21" 7 | "jenkinsci","ssh-credentials-plugin",179,"https://github.com/jenkinsci/ssh-credentials-plugin/pull/179","closed","2023-08-12T08:32:14Z","2023-09-21T16:12:07Z","NotMyFault","2023-08","Test on Java 21" 8 | -------------------------------------------------------------------------------- /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-extractor_darwin_amd64_v1/jenkins-contribution-extractor . 31 | 32 | clean: ## Remove previous build 33 | @rm -f ./jenkins-contribution-extractor 34 | @rm -f ./rm top-submitters_*.csv 35 | @rm -f ./jenkins_commenters_data.csv 36 | @rm -f ./cmd/jenkins_commenters_data.csv 37 | @rm -f ./cover.out 38 | @rm -f ./coverage.txt 39 | @rm -f ./debug.log 40 | @rm -f ./cmd/debug.log 41 | 42 | help: ## Display this help screen 43 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 44 | -------------------------------------------------------------------------------- /cmd/logging.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 | "log" 27 | "os" 28 | "sync" 29 | ) 30 | 31 | type LOGGER struct { 32 | debug *log.Logger 33 | prod *log.Logger 34 | } 35 | 36 | var lock = &sync.Mutex{} 37 | var loggers *LOGGER 38 | 39 | func GetLoggerInstance() *LOGGER { 40 | lock.Lock() 41 | defer lock.Unlock() 42 | 43 | if loggers == nil { 44 | loggers = &LOGGER{} 45 | } 46 | 47 | return loggers 48 | } 49 | func initLoggers() { 50 | loggers := GetLoggerInstance() 51 | f, err := os.OpenFile("debug.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 52 | if err != nil { 53 | fmt.Println("debug log file not created", err.Error()) 54 | } 55 | loggers.debug = log.New(f, "[DEBUG]", log.Ldate|log.Ltime|log.Lmicroseconds|log.LUTC) 56 | loggers.prod = log.New(os.Stderr, "[log]", log.Ldate|log.Ltime|log.Lmicroseconds|log.LUTC) 57 | } 58 | -------------------------------------------------------------------------------- /.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.23.2' 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.23.2' 40 | 41 | - name: Check out code 42 | uses: actions/checkout@v3 43 | 44 | - name: Run Unit tests. 45 | run: | 46 | make test-coverage 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Upload coverage reports to Codecov 51 | uses: codecov/codecov-action@v4.0.1 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | file: ./coverage.txt 55 | 56 | 57 | 58 | build: 59 | runs-on: ubuntu-latest 60 | name: Build and Integration tests 61 | needs: [lint, test] 62 | steps: 63 | - uses: actions/checkout@v4 64 | - uses: actions/setup-go@v4 65 | with: 66 | go-version: '^1.23.2' 67 | - run: go mod download 68 | - name: Validates GO releaser config 69 | uses: goreleaser/goreleaser-action@v5 70 | with: 71 | distribution: goreleaser 72 | args: check 73 | - name: Run GoReleaser 74 | uses: goreleaser/goreleaser-action@v5 75 | with: 76 | distribution: goreleaser 77 | args: release --snapshot --skip=publish --clean 78 | 79 | -------------------------------------------------------------------------------- /cmd/get.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 | "github.com/spf13/cobra" 26 | ) 27 | 28 | // getCmd represents the get command 29 | var getCmd = &cobra.Command{ 30 | Use: "get [commenters|pr]", 31 | Short: "Retrieves data from GitHub (PRs or Commenters)", 32 | Long: `Long description 33 | `, 34 | } 35 | 36 | // Cobra initialize 37 | func init() { 38 | rootCmd.AddCommand(getCmd) 39 | 40 | getCmd.PersistentFlags().StringVarP(&outputFileName, "out", "o", "jenkins_commenters_data.csv", "Output file name.") 41 | getCmd.PersistentFlags().StringVarP(&excludeFileName, "excludeFile", "x", "", "Name of the file containing the github handles to exclude from the data collection.") 42 | getCmd.PersistentFlags().BoolVarP(&globalIsAppend, "append", "a", false, "Appends data to existing output file.") 43 | getCmd.PersistentFlags().BoolVarP(&globalIsNoHeader, "no_header", "", false, "Doesn't add a header to file (implied when appending to existing file).") 44 | 45 | rootCmd.PersistentFlags().BoolVarP(&isRootDebug, "debug", "", false, "Display debug information (super verbose mode)") 46 | 47 | } 48 | -------------------------------------------------------------------------------- /cmd/quota_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 | "testing" 26 | "time" 27 | 28 | "github.com/stretchr/testify/assert" 29 | ) 30 | 31 | func Test_get_quota(t *testing.T) { 32 | tests := []struct { 33 | name string 34 | }{ 35 | { 36 | "Happy case", 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | get_quota() 42 | }) 43 | } 44 | } 45 | 46 | func Test_get_quota_data_v4(t *testing.T) { 47 | tests := []struct { 48 | name string 49 | }{ 50 | {"Happy case"}, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | get_quota_data_v4() 55 | }) 56 | } 57 | } 58 | 59 | func Test_waitForReset(t *testing.T) { 60 | time1 := time.Now() 61 | 62 | seconds_toWait := 10 63 | waitForReset(seconds_toWait) 64 | 65 | time2 := time.Now() 66 | difference := time2.Sub(time1) 67 | 68 | assert.EqualValues(t, seconds_toWait, int(difference.Seconds())) 69 | 70 | } 71 | 72 | func Test_checkIfSufficientQuota(t *testing.T) { 73 | isRootDebug = true 74 | 75 | checkIfSufficientQuota(15) 76 | 77 | //TODO: How do we know that the result was expected ? =>very louzy test 78 | } 79 | -------------------------------------------------------------------------------- /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-extractor :\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-extractor 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 | -------------------------------------------------------------------------------- /cmd/root_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 | "testing" 26 | ) 27 | 28 | func Test_validateHeader(t *testing.T) { 29 | type args struct { 30 | header []string 31 | referenceHeader []string 32 | isVerbose bool 33 | } 34 | tests := []struct { 35 | name string 36 | args args 37 | want bool 38 | }{ 39 | { 40 | "Not expected number of fields", 41 | args{ 42 | header: []string{"field1", "field2"}, 43 | referenceHeader: []string{"field1", "field2", "field3"}, 44 | isVerbose: true, 45 | }, 46 | false, 47 | }, 48 | { 49 | "Not expected field name", 50 | args{ 51 | header: []string{"field1", "FIELD2", "field3"}, 52 | referenceHeader: []string{"field1", "field2", "field3"}, 53 | isVerbose: true, 54 | }, 55 | false, 56 | }, 57 | { 58 | "Happy case", 59 | args{ 60 | header: []string{"field1", "field2", "field3"}, 61 | referenceHeader: []string{"field1", "field2", "field3"}, 62 | isVerbose: true, 63 | }, 64 | true, 65 | }, 66 | } 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | if got := validateHeader(tt.args.header, tt.args.referenceHeader, tt.args.isVerbose); got != tt.want { 70 | t.Errorf("validateHeader() = %v, want %v", got, tt.want) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cmd/get-commenters_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 | func Test_loadPrListFile(t *testing.T) { 34 | type args struct { 35 | fileName string 36 | isVerbose bool 37 | } 38 | tests := []struct { 39 | name string 40 | args args 41 | want []string 42 | want1 bool 43 | }{ 44 | { 45 | "test empty file", 46 | args{ 47 | fileName: "../test-data/empty-submission-list.csv", 48 | isVerbose: true, 49 | }, 50 | nil, true, 51 | }, 52 | // { 53 | // "test file with one data line", 54 | // args{ 55 | // fileName: "../test-data/oneLine-submission-list.csv", 56 | // isVerbose: true, 57 | // }, 58 | // nil,true, 59 | // }, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | got, got1 := loadPrListFile(tt.args.fileName, tt.args.isVerbose) 64 | if !reflect.DeepEqual(got, tt.want) { 65 | t.Errorf("loadPrListFile() got = %v, want %v", got, tt.want) 66 | } 67 | if got1 != tt.want1 { 68 | t.Errorf("loadPrListFile() got1 = %v, want %v", got1, tt.want1) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func Test_ExecuteGetCommenterProcessExcludeIfPresent(t *testing.T) { 75 | actual := new(bytes.Buffer) 76 | rootCmd.SetOut(actual) 77 | rootCmd.SetErr(actual) 78 | rootCmd.SetArgs([]string{"get", "commenters", "../test-data/empty-submission-list.csv", "-x", "nonExistingFile.txt"}) 79 | error := rootCmd.Execute() 80 | 81 | assert.Error(t, error, "Function call should have failed") 82 | 83 | //Error is expected 84 | expectedMsg := "Error: invalid excluded user list => Unable to read input file nonExistingFile.txt: open nonExistingFile.txt: no such file or directory" 85 | lines := strings.Split(actual.String(), "\n") 86 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 87 | } 88 | -------------------------------------------------------------------------------- /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 | "log" 26 | "os" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | // var cfgFile string 32 | var outputFileName string 33 | var excludeFileName string 34 | var ghTokenVar string 35 | var isVerbose bool 36 | var isRootDebug bool 37 | var globalIsAppend bool 38 | var globalIsNoHeader bool 39 | var globalIsBigFile bool 40 | 41 | // if an exclusion file is available, will contain the list of users to exclude 42 | var excludedGithubUsers []string 43 | 44 | // rootCmd represents the base command when called without any subcommands 45 | var rootCmd = &cobra.Command{ 46 | // Use: "jenkins-contribution-extractor [PR list CSV]", 47 | Short: "Retrieve Jenkins related usage stats from GitHub", 48 | Long: `Retrieve data from GitHub that can be useful to evaluate the health and activity of a community. 49 | It currently gets data about Pull Request submitters and commenters on those Pull Requests. 50 | `, 51 | SilenceUsage: true, 52 | } 53 | 54 | // Execute adds all child commands to the root command and sets flags appropriately. 55 | // This is called by main.main(). It only needs to happen once to the rootCmd. 56 | func Execute() { 57 | err := rootCmd.Execute() 58 | if err != nil { 59 | os.Exit(1) 60 | } 61 | } 62 | 63 | // Cobra initialization 64 | func init() { 65 | 66 | rootCmd.PersistentFlags().StringVarP(&ghTokenVar, "token_var", "t", "GITHUB_TOKEN", "The environment variable containing the GitHub token.") 67 | rootCmd.PersistentFlags().BoolVarP(&isVerbose, "verbose", "v", false, "Displays useful info during the extraction.") 68 | 69 | //Disable the Cobra completion options 70 | rootCmd.CompletionOptions.DisableDefaultCmd = true 71 | 72 | // Don't sort flags in alphabetical order 73 | rootCmd.Flags().SortFlags = false 74 | rootCmd.PersistentFlags().SortFlags = false 75 | 76 | err := rootCmd.PersistentFlags().MarkHidden("debug") 77 | if err != nil { 78 | log.Printf("Error hiding debug flag: %v\n", err) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /cmd/get-submitters_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 | "strings" 27 | "testing" 28 | 29 | "github.com/stretchr/testify/assert" 30 | ) 31 | 32 | func Test_getTotalNumberOfItems(t *testing.T) { 33 | type args struct { 34 | searchedOrg string 35 | searchedMonth string 36 | } 37 | tests := []struct { 38 | name string 39 | args args 40 | want int 41 | wantErr bool 42 | }{ 43 | { 44 | "Below 1K", 45 | args{ 46 | searchedOrg: "jenkinsci", 47 | searchedMonth: "2023-09", 48 | }, 49 | 692, false, 50 | }, 51 | { 52 | "Above 1K", 53 | args{ 54 | searchedOrg: "jenkinsci", 55 | searchedMonth: "2020-01", 56 | }, 57 | 1233, false, 58 | }, 59 | } 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | got, err := getTotalNumberOfItems(tt.args.searchedOrg, tt.args.searchedMonth) 63 | if (err != nil) != tt.wantErr { 64 | t.Errorf("getTotalNumberOfItems() error = %v, wantErr %v", err, tt.wantErr) 65 | return 66 | } 67 | if got != tt.want { 68 | t.Errorf("getTotalNumberOfItems() = %v, want %v", got, tt.want) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func Test_performSearch(t *testing.T) { 75 | type args struct { 76 | searchedOrg string 77 | searchedMonth string 78 | } 79 | tests := []struct { 80 | name string 81 | args args 82 | wantErr bool 83 | }{ 84 | { 85 | "test run for debug", 86 | args{ 87 | searchedOrg: "on4kjm", 88 | searchedMonth: "2020-01", 89 | }, 90 | false, 91 | }, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | if err := performSearch(tt.args.searchedOrg, tt.args.searchedMonth); (err != nil) != tt.wantErr { 96 | t.Errorf("performSearch() error = %v, wantErr %v", err, tt.wantErr) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | func Test_ExecuteGetSubmitterProcessExcludeIfPresent(t *testing.T) { 103 | actual := new(bytes.Buffer) 104 | rootCmd.SetOut(actual) 105 | rootCmd.SetErr(actual) 106 | rootCmd.SetArgs([]string{"get", "submitters", "jenkins", "2024-01", "-x", "nonExistingFile.txt"}) 107 | error := rootCmd.Execute() 108 | 109 | assert.Error(t, error, "Function call should have failed") 110 | 111 | //Error is expected 112 | expectedMsg := "Error: invalid excluded user list => Unable to read input file nonExistingFile.txt: open nonExistingFile.txt: no such file or directory" 113 | lines := strings.Split(actual.String(), "\n") 114 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 115 | } 116 | -------------------------------------------------------------------------------- /test-data/pr_per_submitter-2024-04.csv: -------------------------------------------------------------------------------- 1 | user,PR 2 | "basil",69 3 | "gounthar",40 4 | "lemeurherve",33 5 | "smerle33",31 6 | "MarkEWaite",31 7 | "dduportal",28 8 | "janfaracik",25 9 | "jonesbusy",20 10 | "daniel-beck",19 11 | "timja",16 12 | "mawinter69",15 13 | "jglick",15 14 | "NotMyFault",14 15 | "michael-doubez",12 16 | "kmartens27",12 17 | "uhafner",11 18 | "krisstern",11 19 | "zbynek",9 20 | "nikita-tkachenko-datadog",9 21 | "alecharp",9 22 | "susmitagorai29",8 23 | "jtnord",8 24 | "olamy",7 25 | "alextu",7 26 | "StefanSpieker",7 27 | "tamarleviCm",6 28 | "strangelookingnerd",6 29 | "janasrikanth",6 30 | "dwnusbaum",6 31 | "damianszczepanik",6 32 | "AniketNS",6 33 | "waltwilo",5 34 | "mPokornyETM",5 35 | "hashar",5 36 | "BobDu",5 37 | "ysmaoui",4 38 | "vishalhcl-5960",4 39 | "rahulkaukuntla",4 40 | "mikecirioli",4 41 | "maksudur-rahman-maruf",4 42 | "arturmelanchyk",4 43 | "thomasvincent",3 44 | "sridamul",3 45 | "rsandell",3 46 | "rkosegi",3 47 | "pbo-linaro",3 48 | "julieheard",3 49 | "fabiodcasilva",3 50 | "bzzitsme",3 51 | "andreibangau99",3 52 | "Waschndolos",3 53 | "Romain-Geissler-1A",3 54 | "slide",2 55 | "repolevedavaj",2 56 | "pfeuffer",2 57 | "hakre",2 58 | "haidao247",2 59 | "gvazquezmorean",2 60 | "gabriel-check24",2 61 | "froque",2 62 | "clayburn",2 63 | "cdgopal",2 64 | "car-roll",2 65 | "awang-parasoft",2 66 | "amuniz",2 67 | "al3xanndru",2 68 | "SOOS-MMalony",2 69 | "PierreBtz",2 70 | "Munishh992",2 71 | "MarkRx",2 72 | "MarioFuchsTT",2 73 | "Lmh-java",2 74 | "Kiryushin-Andrey",2 75 | "FedeLo13",2 76 | "DominikRusso",2 77 | "Dohbedoh",2 78 | "Anski1",2 79 | "yyuyanyu",1 80 | "wollow12",1 81 | "welandaz",1 82 | "vigneshtestsigma",1 83 | "vemulaanvesh",1 84 | "vahidsh1",1 85 | "tomasbjerre",1 86 | "timbrown5",1 87 | "tilalx",1 88 | "thyldrm",1 89 | "therealsujitk",1 90 | "swatipersistent",1 91 | "stuartrowe",1 92 | "skillcoder",1 93 | "sephiroth-j",1 94 | "rmartine-ias",1 95 | "reinhapa",1 96 | "raul-arabaolaza",1 97 | "rahman-tiobe",1 98 | "radhatiwari01",1 99 | "pyieh",1 100 | "purushotham99",1 101 | "preyankababu",1 102 | "ppettina",1 103 | "paulsavoie",1 104 | "patrikcerbak",1 105 | "owenmartin-toast",1 106 | "oospinar",1 107 | "offa",1 108 | "ns-bliu",1 109 | "nmcc1212",1 110 | "nitin-6542",1 111 | "nikhil-dabhade",1 112 | "nghiadhd-2702",1 113 | "nattofriends",1 114 | "mjeanson",1 115 | "mguillem",1 116 | "mayukothule",1 117 | "mattheimer",1 118 | "lpb1",1 119 | "ljackiewicz",1 120 | "laudrup",1 121 | "langyizhao",1 122 | "kyle-leonhard",1 123 | "kvanzuijlen",1 124 | "kothulemayur",1 125 | "kaushalgupta88",1 126 | "jwojnarowicz",1 127 | "judovana",1 128 | "juanmafabbri",1 129 | "jondaley",1 130 | "jayvirtanen",1 131 | "jandroav",1 132 | "jamiejackson",1 133 | "jahid1209",1 134 | "j-luong",1 135 | "imonteroperez",1 136 | "harshanabandara",1 137 | "growfrow",1 138 | "gabrieleara",1 139 | "franknarf8",1 140 | "francisf",1 141 | "eduard-tita",1 142 | "duyluonganh",1 143 | "dorin7bogdan",1 144 | "delinea-sagar",1 145 | "darpanLalwani",1 146 | "cperrin88",1 147 | "codervijay143",1 148 | "ckullabosch",1 149 | "ckpattar",1 150 | "cconnert",1 151 | "c0d3m0nky",1 152 | "avivbs96",1 153 | "asimell",1 154 | "anniechellah",1 155 | "ankit-patil-hubs",1 156 | "aneveux",1 157 | "ampuscas",1 158 | "alarreine",1 159 | "akhilkolu",1 160 | "abhishekshah-qmetry",1 161 | "Vlatombe",1 162 | "TobiX",1 163 | "TheJonesFoundation",1 164 | "The-Jonsey",1 165 | "TWestling",1 166 | "SofiaVBuitrago",1 167 | "Roy-Lu",1 168 | "Ririshi",1 169 | "Munishkumar92",1 170 | "Martin-vH",1 171 | "Luis-Guga",1 172 | "Louey11",1 173 | "Kevin-CB",1 174 | "Jaspreet1601",1 175 | "IbraheemHaseeb7",1 176 | "Goooler",1 177 | "GerkinDev",1 178 | "GOptimistic",1 179 | "FrogDevelopper",1 180 | "EfrenRey",1 181 | "CJkrishnan",1 182 | "Avi-Gupta1",1 183 | "AshitaSingamsetty",1 184 | "AnoojM",1 185 | "AamirahP",1 186 | "007-PRAKHAR",1 187 | -------------------------------------------------------------------------------- /Notes/debug notes.md: -------------------------------------------------------------------------------- 1 | *** Debug mode enabled *** 2 | PR/comment 3 | - jenkinsci/docker/1711, dduportal, 2023-09-19 13:44:12 +0000 UTC, 1330155191, https://github.com/jenkinsci/docker/pull/1711#discussion_r1330155191 4 | - jenkinsci/docker/1711, dduportal, 2023-09-23 17:26:43 +0000 UTC, 1335046154, https://github.com/jenkinsci/docker/pull/1711#discussion_r1335046154 5 | - jenkinsci/docker/1711, dduportal, 2023-09-23 17:26:57 +0000 UTC, 1335046170, https://github.com/jenkinsci/docker/pull/1711#discussion_r1335046170 6 | - jenkinsci/docker/1711, dduportal, 2023-09-23 17:27:04 +0000 UTC, 1335046196, https://github.com/jenkinsci/docker/pull/1711#discussion_r1335046196 7 | - jenkinsci/docker/1711, dduportal, 2023-09-23 17:27:11 +0000 UTC, 1335046200, https://github.com/jenkinsci/docker/pull/1711#discussion_r1335046200 8 | - jenkinsci/docker/1711, gounthar, 2023-09-23 17:54:26 +0000 UTC, 1335048928, https://github.com/jenkinsci/docker/pull/1711#discussion_r1335048928 9 | 10 | ISSUE/comment 11 | https://pkg.go.dev/github.com/google/go-github/v55@v55.0.0/github#IssuesService.ListComments 12 | GitHub API docs: https://docs.github.com/en/rest/issues/comments#list-issue-comments GitHub API docs: https://docs.github.com/en/rest/issues/comments#list-issue-comments-for-a-repository 13 | 14 | - jenkinsci/docker/1711, gounthar, 2023-09-19 17:09:21 +0000 UTC, https://github.com/jenkinsci/docker/pull/1711#issuecomment-1726106746, %!s(MISSING) 15 | - jenkinsci/docker/1711, gounthar, 2023-09-24 16:49:30 +0000 UTC, https://github.com/jenkinsci/docker/pull/1711#issuecomment-1732618145, %!s(MISSING) 16 | - jenkinsci/docker/1711, gounthar, 2023-09-24 17:03:13 +0000 UTC, https://github.com/jenkinsci/docker/pull/1711#issuecomment-1732620853, %!s(MISSING) 17 | - jenkinsci/docker/1711, dduportal, 2023-09-24 20:03:57 +0000 UTC, https://github.com/jenkinsci/docker/pull/1711#issuecomment-1732657642, %!s(MISSING) 18 | 19 | ----- 20 | 21 | *** Debug mode enabled *** 22 | See "debug.log" for the trace 23 | 24 | Processing "data/submissions-2023-06.csv" 25 | 5% |██████████ | (76/1385, 2 it/s) [47s:13m13s]panic: runtime error: invalid memory address or nil pointer dereference 26 | [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x13a81ff] 27 | 28 | goroutine 1 [running]: 29 | github.com/jmMeessen/jenkins-get-commenters/cmd.load_reviewComments({0xc000284840, 0x9}, {0xc00028484a, 0x1c}, {0x14af07b, 0x2}, {0xc000190dc0, 0x9, 0x0?}) 30 | /home/runner/work/jenkins-get-commenters/jenkins-get-commenters/cmd/get.go:264 +0x1bf 31 | github.com/jmMeessen/jenkins-get-commenters/cmd.getCommenters({0xc000284840, 0x29}, 0x0?, 0x68?, {0x149c8c7, 0x1b}) 32 | /home/runner/work/jenkins-get-commenters/jenkins-get-commenters/cmd/get.go:142 +0x4d9 33 | github.com/jmMeessen/jenkins-get-commenters/cmd.performAction({0x7ff7bfeff527, 0x1c}) 34 | /home/runner/work/jenkins-get-commenters/jenkins-get-commenters/cmd/root.go:280 +0x3bd 35 | github.com/jmMeessen/jenkins-get-commenters/cmd.glob..func5(0xc00012e200?, {0xc000091740, 0x1, 0x1492750?}) 36 | /home/runner/work/jenkins-get-commenters/jenkins-get-commenters/cmd/root.go:76 +0x21f 37 | github.com/spf13/cobra.(*Command).execute(0x1884920, {0xc0000be050, 0x3, 0x3}) 38 | /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.7.0/command.go:944 +0x863 39 | github.com/spf13/cobra.(*Command).ExecuteC(0x1884920) 40 | /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.7.0/command.go:1068 +0x3a5 41 | github.com/spf13/cobra.(*Command).Execute(...) 42 | /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.7.0/command.go:992 43 | github.com/jmMeessen/jenkins-get-commenters/cmd.Execute() 44 | /home/runner/work/jenkins-get-commenters/jenkins-get-commenters/cmd/root.go:88 +0x1a -------------------------------------------------------------------------------- /test-data/pr_per_submitter-2024-03.csv: -------------------------------------------------------------------------------- 1 | user,PR, junk 2 | "basil",69 3 | "gounthar",40 4 | "lemeurherve",33 5 | "smerle33",31 6 | "MarkEWaite",31 7 | "dduportal",28 8 | "janfaracik",25 9 | "jonesbusy",20 10 | "daniel-beck",19 11 | "timja",16 12 | "mawinter69",15 13 | "jglick",15 14 | "NotMyFault",14 15 | "michael-doubez",12 16 | "kmartens27",12 17 | "uhafner",11 18 | "krisstern",11 19 | "zbynek",9 20 | "nikita-tkachenko-datadog",9 21 | "alecharp",9 22 | "susmitagorai29",8 23 | "jtnord",8 24 | "olamy",7 25 | "alextu",7 26 | "StefanSpieker",7 27 | "tamarleviCm",6 28 | "strangelookingnerd",6 29 | "janasrikanth",6 30 | "dwnusbaum",6 31 | "damianszczepanik",6 32 | "AniketNS",6 33 | "waltwilo",5 34 | "mPokornyETM",5 35 | "hashar",5 36 | "BobDu",5 37 | "ysmaoui",4 38 | "vishalhcl-5960",4 39 | "rahulkaukuntla",4 40 | "mikecirioli",4 41 | "maksudur-rahman-maruf",4 42 | "arturmelanchyk",4 43 | "thomasvincent",3 44 | "sridamul",3 45 | "rsandell",3 46 | "rkosegi",3 47 | "pbo-linaro",3 48 | "julieheard",3 49 | "fabiodcasilva",3 50 | "bzzitsme",3 51 | "andreibangau99",3 52 | "Waschndolos",3 53 | "Romain-Geissler-1A",3 54 | "slide",2 55 | "repolevedavaj",2 56 | "pfeuffer",2 57 | "hakre",2 58 | "haidao247",2 59 | "gvazquezmorean",2 60 | "gabriel-check24",2 61 | "froque",2 62 | "clayburn",2 63 | "cdgopal",2 64 | "car-roll",2 65 | "awang-parasoft",2 66 | "amuniz",2 67 | "al3xanndru",2 68 | "SOOS-MMalony",2 69 | "PierreBtz",2 70 | "Munishh992",2 71 | "MarkRx",2 72 | "MarioFuchsTT",2 73 | "Lmh-java",2 74 | "Kiryushin-Andrey",2 75 | "FedeLo13",2 76 | "DominikRusso",2 77 | "Dohbedoh",2 78 | "Anski1",2 79 | "yyuyanyu",1 80 | "wollow12",1 81 | "welandaz",1 82 | "vigneshtestsigma",1 83 | "vemulaanvesh",1 84 | "vahidsh1",1 85 | "tomasbjerre",1 86 | "timbrown5",1 87 | "tilalx",1 88 | "thyldrm",1 89 | "therealsujitk",1 90 | "swatipersistent",1 91 | "stuartrowe",1 92 | "skillcoder",1 93 | "sephiroth-j",1 94 | "rmartine-ias",1 95 | "reinhapa",1 96 | "raul-arabaolaza",1 97 | "rahman-tiobe",1 98 | "radhatiwari01",1 99 | "pyieh",1 100 | "purushotham99",1 101 | "preyankababu",1 102 | "ppettina",1 103 | "paulsavoie",1 104 | "patrikcerbak",1 105 | "owenmartin-toast",1 106 | "oospinar",1 107 | "offa",1 108 | "ns-bliu",1 109 | "nmcc1212",1 110 | "nitin-6542",1 111 | "nikhil-dabhade",1 112 | "nghiadhd-2702",1 113 | "nattofriends",1 114 | "mjeanson",1 115 | "mguillem",1 116 | "mayukothule",1 117 | "mattheimer",1 118 | "lpb1",1 119 | "ljackiewicz",1 120 | "laudrup",1 121 | "langyizhao",1 122 | "kyle-leonhard",1 123 | "kvanzuijlen",1 124 | "kothulemayur",1 125 | "kaushalgupta88",1 126 | "jwojnarowicz",1 127 | "judovana",1 128 | "juanmafabbri",1 129 | "jondaley",1 130 | "jayvirtanen",1 131 | "jandroav",1 132 | "jamiejackson",1 133 | "jahid1209",1 134 | "j-luong",1 135 | "imonteroperez",1 136 | "harshanabandara",1 137 | "growfrow",1 138 | "gabrieleara",1 139 | "franknarf8",1 140 | "francisf",1 141 | "eduard-tita",1 142 | "duyluonganh",1 143 | "dorin7bogdan",1 144 | "delinea-sagar",1 145 | "darpanLalwani",1 146 | "cperrin88",1 147 | "codervijay143",1 148 | "ckullabosch",1 149 | "ckpattar",1 150 | "cconnert",1 151 | "c0d3m0nky",1 152 | "avivbs96",1 153 | "asimell",1 154 | "anniechellah",1 155 | "ankit-patil-hubs",1 156 | "aneveux",1 157 | "ampuscas",1 158 | "alarreine",1 159 | "akhilkolu",1 160 | "abhishekshah-qmetry",1 161 | "Vlatombe",1 162 | "TobiX",1 163 | "TheJonesFoundation",1 164 | "The-Jonsey",1 165 | "TWestling",1 166 | "SofiaVBuitrago",1 167 | "Roy-Lu",1 168 | "Ririshi",1 169 | "Munishkumar92",1 170 | "Martin-vH",1 171 | "Luis-Guga",1 172 | "Louey11",1 173 | "Kevin-CB",1 174 | "Jaspreet1601",1 175 | "IbraheemHaseeb7",1 176 | "Goooler",1 177 | "GerkinDev",1 178 | "GOptimistic",1 179 | "FrogDevelopper",1 180 | "EfrenRey",1 181 | "CJkrishnan",1 182 | "Avi-Gupta1",1 183 | "AshitaSingamsetty",1 184 | "AnoojM",1 185 | "AamirahP",1 186 | "007-PRAKHAR",1 187 | -------------------------------------------------------------------------------- /cmd/exclusions.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 | "bufio" 26 | "fmt" 27 | "os" 28 | "regexp" 29 | "strings" 30 | ) 31 | 32 | // Loads the list of gitHub users to exclude from the count 33 | func load_exclusions(exclusions_filename string) (error, []string) { 34 | 35 | if len(exclusions_filename) == 0 { 36 | return fmt.Errorf("No filename provided."), nil 37 | } 38 | 39 | f, err := os.Open(exclusions_filename) 40 | if err != nil { 41 | return fmt.Errorf("Unable to read input file %s: %v\n", exclusions_filename, err), nil 42 | } 43 | defer f.Close() 44 | 45 | var loadedFile []string 46 | 47 | scanner := bufio.NewScanner(f) 48 | for scanner.Scan() { 49 | loadedFile = append(loadedFile, scanner.Text()) 50 | } 51 | 52 | if err := scanner.Err(); err != nil { 53 | return fmt.Errorf("Error loading \"%s\": %v", exclusions_filename, err), nil 54 | } 55 | 56 | uncommentedList := removeComments(loadedFile) 57 | 58 | if validationError := validate_loadedFile(uncommentedList); validationError != nil { 59 | return validationError, nil 60 | } else { 61 | return nil, uncommentedList 62 | } 63 | } 64 | 65 | // returns a string list with comments removed 66 | func removeComments(rawList []string) []string { 67 | var cleanedList []string 68 | 69 | for _, lineToCheck := range rawList { 70 | if isCommentedLine(lineToCheck) { 71 | continue 72 | } 73 | 74 | cleanedList = append(cleanedList, removeInlineComment(lineToCheck)) 75 | } 76 | return cleanedList 77 | } 78 | 79 | // Validates whether the supplied string slice is composed of properly formatted GitHub users 80 | func validate_loadedFile(loadedFile []string) error { 81 | if len(loadedFile) == 0 { 82 | return fmt.Errorf("Error: empty file") 83 | } 84 | 85 | for _, githubUserToCheck := range loadedFile { 86 | if !isValidOrgFormat(githubUserToCheck) { 87 | return fmt.Errorf("Invalid excluded user \"%s\" (does not match GitHub user syntax)", githubUserToCheck) 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | 94 | var lineComment_regexp = regexp.MustCompile(`^\s*#`) 95 | var emptyLine_regexp = regexp.MustCompile(`^\s*$`) 96 | 97 | // Returns true if the whole line is commented or contains no data 98 | func isCommentedLine(line string) bool { 99 | if lineComment_regexp.MatchString(line) { 100 | return true 101 | } 102 | if emptyLine_regexp.MatchString(line) { 103 | return true 104 | } 105 | return false 106 | } 107 | 108 | func removeInlineComment(input string) string { 109 | var output string 110 | 111 | // Take what is before the "#" 112 | output = strings.Split(input, "#")[0] 113 | 114 | // Remove any trailing white spaces from the splitted string 115 | output = strings.TrimSpace(output) 116 | 117 | return output 118 | } 119 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: jenkins-contribution-extractor 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-extractor/cmd.version={{.Version}} 28 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-extractor/cmd.commit={{.Commit}} 29 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-extractor/cmd.date={{.Date}} 30 | - -s -w -X github.com/jenkins-infra/jenkins-contribution-extractor/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-extractor 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-extractor/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: jenkins-infra-team@googlegroups.com 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-extractor" 101 | 102 | # Template of your app's description. 103 | # Default is empty. 104 | description: "Jenkins Contribution data extractor and 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-extractor version -d" 123 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= 2 | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 3 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 4 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 5 | github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= 6 | github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 12 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 13 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= 15 | github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= 16 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 17 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 18 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 19 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 20 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 21 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 22 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 23 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 24 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 25 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 27 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 32 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 33 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 34 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 35 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 36 | github.com/schollz/progressbar/v3 v3.17.1 h1:bI1MTaoQO+v5kzklBjYNRQLoVpe0zbyRZNK6DFkVC5U= 37 | github.com/schollz/progressbar/v3 v3.17.1/go.mod h1:RzqpnsPQNjUyIgdglUjRLgD7sVnxN1wpmBMV+UiEbL4= 38 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= 39 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 40 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 41 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 42 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 43 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 44 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 45 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 46 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 47 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 48 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 49 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 50 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 51 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 52 | golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= 53 | golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 54 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 55 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 56 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 57 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 58 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 61 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 63 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /cmd/exclusions_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 | "reflect" 26 | "testing" 27 | ) 28 | 29 | func Test_load_exclusions(t *testing.T) { 30 | type args struct { 31 | exclusions_filename string 32 | } 33 | tests := []struct { 34 | name string 35 | args args 36 | want []string 37 | wantErr bool 38 | }{ 39 | { 40 | "Empty filename provided", 41 | args{ 42 | exclusions_filename: "", 43 | }, 44 | nil, 45 | true, 46 | }, 47 | { 48 | "filename does not exist", 49 | args{ 50 | exclusions_filename: "inexistentFile.txt", 51 | }, 52 | nil, 53 | true, 54 | }, 55 | { 56 | "happy case", 57 | args{ 58 | exclusions_filename: "../test-data/exclusions.txt", 59 | }, 60 | []string{"user1", "user2"}, 61 | false, 62 | }, 63 | } 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | err, got := load_exclusions(tt.args.exclusions_filename) 67 | if (err != nil) != tt.wantErr { 68 | t.Errorf("load_exclusions() error = %v, wantErr %v", err, tt.wantErr) 69 | return 70 | } 71 | if !reflect.DeepEqual(got, tt.want) { 72 | t.Errorf("load_exclusions() = %v, want %v", got, tt.want) 73 | } 74 | }) 75 | } 76 | } 77 | 78 | var emptyStringList []string 79 | 80 | func Test_validate_loadedFile(t *testing.T) { 81 | type args struct { 82 | loadedFile []string 83 | } 84 | tests := []struct { 85 | name string 86 | args args 87 | wantErr bool 88 | }{ 89 | { 90 | "empty list", 91 | args{ 92 | loadedFile: emptyStringList, 93 | }, 94 | true, 95 | }, 96 | { 97 | "Happy Case", 98 | args{ 99 | loadedFile: []string{"user1", "user2"}, 100 | }, 101 | false, 102 | }, 103 | { 104 | "Happy Case - single user", 105 | args{ 106 | loadedFile: []string{"user1"}, 107 | }, 108 | false, 109 | }, 110 | { 111 | "space separated users on one line", 112 | args{ 113 | loadedFile: []string{"user1", "user2 user3 user4"}, 114 | }, 115 | true, 116 | }, 117 | { 118 | "Bad github user", 119 | args{ 120 | loadedFile: []string{"user1", "user%2"}, 121 | }, 122 | true, 123 | }, 124 | } 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | if err := validate_loadedFile(tt.args.loadedFile); (err != nil) != tt.wantErr { 128 | t.Errorf("validate_loadedFile() error = %v, wantErr %v", err, tt.wantErr) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func Test_removeComments(t *testing.T) { 135 | type args struct { 136 | rawList []string 137 | } 138 | tests := []struct { 139 | name string 140 | args args 141 | want []string 142 | }{ 143 | { 144 | "no comment", 145 | args{rawList: []string{"user1", "user2"}}, 146 | []string{"user1", "user2"}, 147 | }, 148 | { 149 | "line comment", 150 | args{rawList: []string{"# comment", "user1", "user2"}}, 151 | []string{"user1", "user2"}, 152 | }, 153 | { 154 | "empty line", 155 | args{rawList: []string{" ", "user1", "user2"}}, 156 | []string{"user1", "user2"}, 157 | }, 158 | { 159 | "empty line 2", 160 | args{rawList: []string{"", "user1", "user2"}}, 161 | []string{"user1", "user2"}, 162 | }, 163 | { 164 | "inline comment 1", 165 | args{rawList: []string{"", "user1 #comment", "user2"}}, 166 | []string{"user1", "user2"}, 167 | }, 168 | { 169 | "inline comment 2", 170 | args{rawList: []string{"", "user1 # comment#", "user2"}}, 171 | []string{"user1", "user2"}, 172 | }, 173 | { 174 | "spaces around entry", 175 | args{rawList: []string{"", "user1 ", " user2 "}}, 176 | []string{"user1", "user2"}, 177 | }, 178 | } 179 | for _, tt := range tests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | if got := removeComments(tt.args.rawList); !reflect.DeepEqual(got, tt.want) { 182 | t.Errorf("removeComments() = %v, want %v", got, tt.want) 183 | } 184 | }) 185 | } 186 | } 187 | 188 | func Test_isCommentedLine(t *testing.T) { 189 | type args struct { 190 | line string 191 | } 192 | tests := []struct { 193 | name string 194 | args args 195 | want bool 196 | }{ 197 | { 198 | "no comment", 199 | args{line: "user"}, 200 | false, 201 | }, 202 | { 203 | "Commented 1", 204 | args{line: "# this is a comment"}, 205 | true, 206 | }, 207 | { 208 | "Commented 2", 209 | args{line: " # this is a comment"}, 210 | true, 211 | }, 212 | { 213 | "Commented 3", 214 | args{line: "#this is a comment"}, 215 | true, 216 | }, 217 | { 218 | "Commented 4", 219 | args{line: "#this is #a comment"}, 220 | true, 221 | }, 222 | { 223 | "Empty line 1", 224 | args{line: " "}, 225 | true, 226 | }, 227 | { 228 | "Empty line 2", 229 | args{line: ""}, 230 | true, 231 | }, 232 | } 233 | for _, tt := range tests { 234 | t.Run(tt.name, func(t *testing.T) { 235 | if got := isCommentedLine(tt.args.line); got != tt.want { 236 | t.Errorf("isCommentedLine() = %v, want %v", got, tt.want) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /cmd/honor_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 | "bytes" 26 | "path/filepath" 27 | "testing" 28 | 29 | "github.com/stretchr/testify/assert" 30 | ) 31 | 32 | func Test_performHonorContributorSelection_params(t *testing.T) { 33 | type args struct { 34 | dataDir string 35 | outputFileName string 36 | monthToSelectFrom string 37 | } 38 | tests := []struct { 39 | name string 40 | args args 41 | wantErr bool 42 | }{ 43 | { 44 | "inexistent data directory", 45 | args{ 46 | dataDir: "inexistentDir", 47 | monthToSelectFrom: "2024-04", 48 | }, 49 | true, 50 | }, 51 | { 52 | "valid data directory and month", 53 | args{ 54 | dataDir: "../test-data", 55 | monthToSelectFrom: "2024-04", 56 | }, 57 | false, 58 | }, 59 | { 60 | "invalid month", 61 | args{ 62 | monthToSelectFrom: "junkMonth", 63 | dataDir: "../test-data", 64 | }, 65 | true, 66 | }, 67 | { 68 | "invalid header in input file", 69 | args{ 70 | dataDir: "../test-data", 71 | monthToSelectFrom: "2024-03", 72 | }, 73 | true, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | if err := performHonorContributorSelection(tt.args.dataDir, tt.args.outputFileName, tt.args.monthToSelectFrom); (err != nil) != tt.wantErr { 79 | t.Errorf("performHonorContributorSelection() error = %v, wantErr %v", err, tt.wantErr) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func Test_honorCommand_paramCheck_noMonth(t *testing.T) { 86 | //Setup environment 87 | actual := new(bytes.Buffer) 88 | rootCmd.SetOut(actual) 89 | rootCmd.SetErr(actual) 90 | var commandArguments []string 91 | commandArguments = append(commandArguments, "honor", "--data_dir=../test-data") 92 | rootCmd.SetArgs(commandArguments) 93 | 94 | // execute command 95 | error := rootCmd.Execute() 96 | 97 | // check results 98 | assert.ErrorContains(t, error, "\"month\" argument is missing.", "Call should have failed with expected error.") 99 | } 100 | 101 | func Test_honorCommand_paramCheck_invalidMonth(t *testing.T) { 102 | //Setup environment 103 | actual := new(bytes.Buffer) 104 | rootCmd.SetOut(actual) 105 | rootCmd.SetErr(actual) 106 | var commandArguments []string 107 | commandArguments = append(commandArguments, "honor", "junkMonth", "--data_dir=../test-data") 108 | rootCmd.SetArgs(commandArguments) 109 | 110 | // execute command 111 | error := rootCmd.Execute() 112 | 113 | // check results 114 | assert.ErrorContains(t, error, "\"junkMonth\" is not a valid month.", "Call should have failed with expected error.") 115 | } 116 | 117 | func Test_honorCommand_integrationTest_verbose(t *testing.T) { 118 | 119 | // Setup test environment 120 | tempDir := t.TempDir() 121 | // duplicate the file but keep the original filename 122 | dataFilename, err := duplicateFile("../test-data/pr_per_submitter-2024-04.csv", tempDir, false) 123 | 124 | assert.NoError(t, err, "Unexpected data file duplication error") 125 | assert.NotEmpty(t, dataFilename, "Failure to copy data file") 126 | 127 | actual := new(bytes.Buffer) 128 | rootCmd.SetOut(actual) 129 | rootCmd.SetErr(actual) 130 | var commandArguments []string 131 | commandArguments = append(commandArguments, "honor", "2024-04", "--data_dir="+tempDir, "--verbose") 132 | rootCmd.SetArgs(commandArguments) 133 | 134 | // execute command 135 | error := rootCmd.Execute() 136 | 137 | // check results 138 | assert.NoError(t, error, "Call should not have failed") 139 | assert.NotEmpty(t, filepath.Join(tempDir, "honored_contributor.csv"), "Failure to generate target file") 140 | //TODO: check that it has the correct header 141 | //TODO: check that the data (second line) has usable data (is this worth it?) 142 | 143 | } 144 | 145 | func Test_stringifySlice(t *testing.T) { 146 | type args struct { 147 | s []string 148 | } 149 | tests := []struct { 150 | name string 151 | args args 152 | want string 153 | }{ 154 | { 155 | "happy case", 156 | args{s: []string{"aaa", "bbb", "ccc"}}, 157 | "aaa bbb ccc", 158 | }, 159 | { 160 | "Single item case", 161 | args{s: []string{"aaa"}}, 162 | "aaa", 163 | }, 164 | } 165 | for _, tt := range tests { 166 | t.Run(tt.name, func(t *testing.T) { 167 | if got := stringifySlice(tt.args.s); got != tt.want { 168 | t.Errorf("stringifySlice() = %v, want %v", got, tt.want) 169 | } 170 | }) 171 | } 172 | } 173 | 174 | func Test_generateHonoredContributorDataAsCSV(t *testing.T) { 175 | type args struct { 176 | contributorData HonoredContributorData 177 | } 178 | tests := []struct { 179 | name string 180 | args args 181 | want string 182 | }{ 183 | { 184 | "typical case", 185 | args{ 186 | contributorData: HonoredContributorData{ 187 | handle: "GH_handle", 188 | fullName: "author_fullName", 189 | authorURL: "author_url", 190 | authorAvatarUrl: "author_avatar", 191 | authorCompany: "a_company", 192 | month: "a_month", 193 | totalPRs_found: "PR_found", 194 | totalPRs_expected: "PR_expected", 195 | repositories: "repositories", 196 | }, 197 | }, 198 | "\"a_month\", \"GH_handle\", \"author_fullName\", \"a_company\", \"author_url\", \"author_avatar\", \"PR_found\", \"repositories\"", 199 | }, 200 | { 201 | "with empty fields", 202 | args{ 203 | contributorData: HonoredContributorData{ 204 | handle: "GH_handle", 205 | fullName: "", 206 | authorURL: "author_url", 207 | authorAvatarUrl: "author_avatar", 208 | authorCompany: "", 209 | month: "a_month", 210 | totalPRs_found: "PR_found", 211 | totalPRs_expected: "PR_expected", 212 | repositories: "repositories", 213 | }, 214 | }, 215 | "\"a_month\", \"GH_handle\", \"\", \"\", \"author_url\", \"author_avatar\", \"PR_found\", \"repositories\"", 216 | }, 217 | } 218 | for _, tt := range tests { 219 | t.Run(tt.name, func(t *testing.T) { 220 | if got := generateHonoredContributorDataAsCSV(tt.args.contributorData); got != tt.want { 221 | t.Errorf("generateHonoredContributorDataAsCSV() = %v, want %v", got, tt.want) 222 | } 223 | }) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /cmd/quota.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 | "context" 26 | "fmt" 27 | "log" 28 | "time" 29 | 30 | "github.com/google/go-github/v55/github" 31 | "github.com/shurcooL/githubv4" 32 | "github.com/spf13/cobra" 33 | "golang.org/x/oauth2" 34 | 35 | //See https://github.com/schollz/progressbar 36 | "github.com/schollz/progressbar/v3" 37 | ) 38 | 39 | // quotaCmd represents the quota command 40 | var quotaCmd = &cobra.Command{ 41 | Use: "quota", 42 | Short: "Gets the current GitHub API quota status", 43 | Long: `Gets the current GitHub API quota status. 44 | 45 | The command displays the V3 (Rest) API quota as well as the V4 (GraphQL) API quota. 46 | 47 | Note that the GitHub token must be defined.`, 48 | Run: func(cmd *cobra.Command, args []string) { 49 | get_quota() 50 | }, 51 | } 52 | 53 | func init() { 54 | rootCmd.AddCommand(quotaCmd) 55 | } 56 | 57 | // --- 58 | // Retrieves the GitHub API Quota 59 | func get_quota() { 60 | limit, remaining := get_quota_data() 61 | fmt.Printf("V3 Limit: %d \nV3 Remaining %d \n\n", limit, remaining) 62 | 63 | limit_v4, remaining_v4, resetTimeString, secondsToGo := get_quota_data_v4() 64 | 65 | fmt.Printf("V4 Limit: %d \nV4 Remaining: %d \nV4 Reset time: %s (in %d secs)\n", limit_v4, remaining_v4, resetTimeString, secondsToGo) 66 | } 67 | 68 | // Retrieves the GitHub Quota. 69 | func get_quota_data() (limit int, remaining int) { 70 | // retrieve the token value from the specified environment variable 71 | // ghTokenVar is global and set by the CLI parser 72 | ghToken := loadGitHubToken(ghTokenVar) 73 | 74 | client := github.NewClient(nil).WithAuthToken(ghToken) 75 | 76 | limitsData, _, err := client.RateLimits(context.Background()) 77 | if err != nil { 78 | log.Printf("Error getting limit: %v", err) 79 | return 0, 0 80 | } 81 | return limitsData.Core.Limit, limitsData.Core.Remaining 82 | } 83 | 84 | /* 85 | query { 86 | viewer { 87 | login 88 | } 89 | rateLimit { 90 | limit 91 | cost 92 | remaining 93 | resetAt 94 | } 95 | } 96 | */ 97 | 98 | var quotaQuery struct { 99 | Viewer struct { 100 | Login string 101 | } 102 | RateLimit struct { 103 | Limit int 104 | Cost int 105 | Remaining int 106 | ResetAt time.Time 107 | } 108 | } 109 | 110 | func get_quota_data_v4() (limit int, remaining int, resetAt string, secondsToReset int) { 111 | // retrieve the token value from the specified environment variable 112 | // ghTokenVar is global and set by the CLI parser 113 | ghToken := loadGitHubToken(ghTokenVar) 114 | src := oauth2.StaticTokenSource( 115 | &oauth2.Token{AccessToken: ghToken}, 116 | ) 117 | httpClient := oauth2.NewClient(context.Background(), src) 118 | client := githubv4.NewClient(httpClient) 119 | 120 | err := client.Query(context.Background(), "aQuery, nil) 121 | if err != nil { 122 | //FIXME: Better error handling 123 | log.Panic(err) 124 | } 125 | 126 | // pretty print the reset time (UTC) 127 | reset_time := quotaQuery.RateLimit.ResetAt 128 | resetTimeString := reset_time.Format(time.RFC1123) 129 | 130 | // compute how many seconds are before reset 131 | now := time.Now() 132 | diff := reset_time.Sub(now) 133 | secondsToGo := int(diff.Seconds()) 134 | 135 | return quotaQuery.RateLimit.Limit, quotaQuery.RateLimit.Remaining, resetTimeString, secondsToGo 136 | } 137 | 138 | // Get's the V4 quota, checks whether there is enough quota. If not will wait for the reset 139 | func checkIfSufficientQuota(expectedLoad int) { 140 | // initialize we are called outside the normal flow 141 | initLoggers() 142 | 143 | limit, remaining, resetAt, secondsToReset := get_quota_data_v4() 144 | if isRootDebug || isDebugGet { 145 | loggers.debug.Printf("Quota: %d/%d (%d secs -> %s\n", remaining, limit, secondsToReset, resetAt) 146 | loggers.debug.Printf("Requesting to process %d\n", expectedLoad) 147 | } 148 | 149 | globalIsBigFile = false 150 | 151 | if expectedLoad >= limit { 152 | if isRootDebug || isDebugGet { 153 | loggers.debug.Printf("Expected load (%d) is higher then limit (%d)\n", expectedLoad, limit) 154 | } 155 | fmt.Printf("Expected load (%d) is higher then limit (%d)\n Crossing fingers and continuing...\n", expectedLoad, limit) 156 | globalIsBigFile = true 157 | return 158 | } 159 | 160 | if (expectedLoad + 20) > remaining { 161 | //Not enough resources, we need to wait 162 | waitForReset(secondsToReset) 163 | } 164 | // Else we do nothing as we are good to go. 165 | } 166 | 167 | // Used in the Get Submitters (we get the quota with the call) 168 | func checkIfSufficientQuota_2(expectedLoad int, remaining int, limit int, resetAt time.Time) { 169 | // initialize we are called outside the normal flow 170 | initLoggers() 171 | 172 | // pretty print the reset time (UTC) 173 | resetTimeString := resetAt.Format(time.RFC1123) 174 | 175 | // compute how many seconds are before reset 176 | now := time.Now() 177 | diff := resetAt.Sub(now) 178 | secondsToGo := int(diff.Seconds()) 179 | 180 | if isRootDebug || isDebugGet { 181 | loggers.debug.Printf("Quota: %d/%d (%d secs -> %s\n", remaining, limit, secondsToGo, resetTimeString) 182 | loggers.debug.Printf("Requesting to process %d\n", expectedLoad) 183 | } 184 | 185 | globalIsBigFile = false 186 | 187 | if expectedLoad >= limit { 188 | if isRootDebug || isDebugGet { 189 | loggers.debug.Printf("Expected load (%d) is higher then limit (%d)\n", expectedLoad, limit) 190 | } 191 | fmt.Printf("Expected load (%d) is higher then limit (%d)\n Crossing fingers and continuing...\n", expectedLoad, limit) 192 | globalIsBigFile = true 193 | return 194 | } 195 | 196 | if (expectedLoad + 20) > remaining { 197 | //Not enough resources, we need to wait 198 | waitForReset(secondsToGo) 199 | } 200 | // Else we do nothing as we are good to go. 201 | } 202 | 203 | // Wait for a certain number of seconds 204 | func waitForReset(secondsToReset int) { 205 | //TODO: check input value 206 | 207 | bar := progressbar.NewOptions(secondsToReset, 208 | progressbar.OptionShowBytes(false), 209 | progressbar.OptionSetDescription("Waiting for quota reset "), 210 | progressbar.OptionSetPredictTime(false), 211 | progressbar.OptionShowBytes(false), 212 | progressbar.OptionFullWidth(), 213 | progressbar.OptionShowCount(), 214 | progressbar.OptionClearOnFinish(), 215 | ) 216 | 217 | for i := 0; i < secondsToReset; i++ { 218 | err := bar.Add(1) 219 | if err != nil { 220 | log.Printf("Unexpected error updating progress bar (%v)\n", err) 221 | } 222 | time.Sleep(1 * time.Second) 223 | } 224 | 225 | // Clear the progress bar 226 | bar.Reset() 227 | err := bar.Finish() 228 | if err != nil { 229 | log.Printf("Unexpected error clearing progress bar (%v)\n", err) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /cmd/get-commenters.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 | 30 | "regexp" 31 | "strings" 32 | 33 | //See https://github.com/schollz/progressbar 34 | "github.com/schollz/progressbar/v3" 35 | "github.com/spf13/cobra" 36 | ) 37 | 38 | // commentersCmd represents the commenters command 39 | var commentersCmd = &cobra.Command{ 40 | Use: "commenters [PR list CSV filename]", 41 | Short: "Get the commenters for a single PR or a set of PRs listed in a CSV file", 42 | Long: `Retrieve the Pull Request commenters. 43 | It is possible to either pass a (CSV) list of PRs or to specify a single PR. 44 | 45 | The CSV list of PRs must be in the form of \"org,repository,number,url,state,created_at,merged_at,user.login,month_year,title\" 46 | Such a CSV is generated by the jenkins submitter extractions tool (\"jenkins-contribution-extractor get submitters\"). 47 | 48 | To extract the commenters for a single PR, use the "forPR" sub-command. 49 | `, 50 | Args: func(cmd *cobra.Command, args []string) error { 51 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 52 | return err 53 | } 54 | if !fileExist(args[0]) { 55 | return fmt.Errorf("Invalid file\n") 56 | } 57 | 58 | // We probably have a file with users to exclude 59 | if excludeFileName != "" { 60 | var err error 61 | err, excludedGithubUsers = load_exclusions(excludeFileName) 62 | if err != nil { 63 | return fmt.Errorf("invalid excluded user list => %v\n", err) 64 | } 65 | } 66 | 67 | return nil 68 | }, 69 | Run: func(cmd *cobra.Command, args []string) { 70 | // Debug flag is hidden 71 | initLoggers() 72 | if isRootDebug { 73 | loggers.debug.Println("******** New \"Get Commenters\" debug session ********") 74 | } 75 | 76 | if isRootDebug { 77 | fmt.Print("*** Debug mode enabled ***\nSee \"debug.log\" for the trace\n\n") 78 | 79 | limit, remaining, _, _ := get_quota_data_v4() 80 | loggers.debug.Printf("Start quota: %d/%d\n", remaining, limit) 81 | } 82 | 83 | performAction(args[0]) 84 | 85 | if isRootDebug { 86 | limit, remaining, _, _ := get_quota_data_v4() 87 | loggers.debug.Printf("End quota: %d/%d\n", remaining, limit) 88 | } 89 | }, 90 | } 91 | 92 | func init() { 93 | getCmd.AddCommand(commentersCmd) 94 | 95 | } 96 | 97 | var referenceSubmitterCSVheader = []string{"org", "repository", "number", "url", "state", "created_at", "merged_at", "user.login", "month_year", "title"} 98 | 99 | // Loads the data from a file and try to parse it as a CSV 100 | func loadPrListFile(fileName string, isVerbose bool) ([]string, bool) { 101 | 102 | f, err := os.Open(fileName) 103 | if err != nil { 104 | log.Printf("Unable to read input file "+fileName+"\n", err) 105 | return nil, false 106 | } 107 | defer f.Close() 108 | 109 | r := csv.NewReader(f) 110 | 111 | headerLine, err1 := r.Read() 112 | if err1 != nil { 113 | log.Printf("Unexpected error loading"+fileName+"\n", err) 114 | return nil, false 115 | } 116 | 117 | if isVerbose { 118 | fmt.Println("Checking input file") 119 | } 120 | 121 | if !validateHeader(headerLine, referenceSubmitterCSVheader, isVerbose) { 122 | fmt.Println(" Error: header is incorrect.") 123 | return nil, false 124 | } else { 125 | if isVerbose { 126 | fmt.Printf(" - Header is correct\n") 127 | } 128 | } 129 | 130 | records, err := r.ReadAll() 131 | if err != nil { 132 | log.Printf("Unexpected error loading \""+fileName+"\"\n", err) 133 | return nil, false 134 | } 135 | 136 | if len(records) < 1 { 137 | fmt.Printf("Error: No data available after the header\n") 138 | return nil, true 139 | } 140 | if isVerbose { 141 | fmt.Println(" - At least one Pull Request data available") 142 | } 143 | 144 | var prList []string 145 | prj_regexp, _ := regexp.Compile(`^[\w-\.]+$`) // see https://stackoverflow.com/questions/59081778/rules-for-special-characters-in-github-repository-name 146 | pr_regexp, _ := regexp.Compile(`^\d+$`) 147 | 148 | // Check the loaded data 149 | for _, dataLine := range records { 150 | 151 | org := dataLine[0] 152 | if !isValidOrgFormat(org) { 153 | if isVerbose { 154 | fmt.Printf(" Error: ORG field \"%s\" doesn't seem to be a valid GitHub org.\n", org) 155 | } 156 | if isRootDebug { 157 | loggers.debug.Printf(" Error: ORG field \"%s\" doesn't seem to be a valid GitHub org.\n", org) 158 | } 159 | return nil, false 160 | } 161 | 162 | // project name must be "^[\w-\.]+$" 163 | prj := dataLine[1] 164 | if !prj_regexp.MatchString(strings.ToLower(prj)) { 165 | if isVerbose { 166 | fmt.Printf(" Error: PRJ field \"%s\" is not of the expected format", prj) 167 | } 168 | if isRootDebug { 169 | loggers.debug.Printf(" Error: PRJ field \"%s\" is not of the expected format", prj) 170 | } 171 | return nil, false 172 | } 173 | 174 | // PR number must be a number 175 | prNbr := dataLine[2] 176 | if !pr_regexp.MatchString(prNbr) { 177 | if isVerbose { 178 | fmt.Printf(" Error: PR field \"%s\" is not a (positive) number", prNbr) 179 | } 180 | if isRootDebug { 181 | loggers.debug.Printf(" Error: PR field \"%s\" is not a (positive) number", prNbr) 182 | } 183 | return nil, false 184 | } 185 | 186 | prInfo := fmt.Sprintf("%s/%s/%s", org, prj, prNbr) 187 | prList = append(prList, prInfo) 188 | 189 | } 190 | 191 | if isVerbose { 192 | fmt.Printf("Successfully loaded \"%s\" (%d Pull Request to analyze)\n\n", fileName, len(prList)) 193 | } 194 | 195 | return prList, true 196 | } 197 | 198 | // ************** 199 | // ************** 200 | 201 | // This is where it happens 202 | func performAction(inputFile string) { 203 | 204 | fmt.Printf("Processing \"%s\"\n", inputFile) 205 | if isRootDebug { 206 | loggers.debug.Printf("Processing \"%s\"\n", inputFile) 207 | } 208 | 209 | // read the relevant data from the file (and checking it) 210 | prList, result := loadPrListFile(inputFile, isVerbose) 211 | if !result { 212 | fmt.Printf("Could not load \"%s\"\n", inputFile) 213 | os.Exit(1) 214 | } 215 | 216 | isAppend := globalIsAppend 217 | if !globalIsAppend { 218 | // Meaning that we need to create a new file 219 | if fileExist(outputFileName) { 220 | os.Remove(outputFileName) 221 | } 222 | isAppend = true 223 | } 224 | 225 | //check if we have enough quota left to process the whole file 226 | checkIfSufficientQuota(len(prList)) 227 | 228 | var bar *progressbar.ProgressBar 229 | if !isVerbose { 230 | bar = progressbar.Default(int64(len(prList))) 231 | } 232 | 233 | nbrPR_noComment := 0 234 | nbrPR_withComments := 0 235 | totalComments := 0 236 | for _, pr_line := range prList { 237 | //Process the line 238 | nbrOfComments := getCommenters(pr_line, isAppend, globalIsNoHeader, outputFileName) 239 | 240 | totalComments = totalComments + nbrOfComments 241 | //do some accounting 242 | if nbrOfComments == 0 { 243 | nbrPR_noComment++ 244 | } else { 245 | nbrPR_withComments++ 246 | } 247 | 248 | // update the progress bar if in quiet mode 249 | if !isVerbose { 250 | err := bar.Add(1) 251 | if err != nil { 252 | log.Printf("Unexpected error updating progress bar (%v)\n", err) 253 | } 254 | } 255 | } 256 | fmt.Printf("Nbr of PR without comments: %d\n", nbrPR_noComment) 257 | fmt.Printf("Nbr of PR with comments: %d\n", nbrPR_withComments) 258 | fmt.Printf("Total comments: %d\n", totalComments) 259 | 260 | if isRootDebug { 261 | loggers.debug.Printf("Nbr of PR without comments: %d\n", nbrPR_noComment) 262 | loggers.debug.Printf("Nbr of PR with comments: %d\n", nbrPR_withComments) 263 | loggers.debug.Printf("Total comments: %d\n", totalComments) 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /cmd/remove.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 | "os" 28 | "path/filepath" 29 | "regexp" 30 | "strings" 31 | "time" 32 | 33 | "github.com/spf13/cobra" 34 | ) 35 | 36 | // Flag indicating whether a backup of the file is required. 37 | var remove_requireBackup bool 38 | 39 | // removeCmd represents the remove command 40 | var removeCmd = &cobra.Command{ 41 | Use: "remove ", 42 | Short: "Removes given user's data in CSV", 43 | Long: `This command will remove, for a given user, every data line from the data CSV. 44 | A backup of the treated file can be requested (default). 45 | If the user starts with "list:", the rest of the parameter is interpreted as the path to a 46 | list of users to exclude (same format as for the GET command). 47 | `, 48 | Args: func(cmd *cobra.Command, args []string) error { 49 | //call requires two parameters (org and month) 50 | if err := cobra.MinimumNArgs(2)(cmd, args); err != nil { 51 | return err 52 | } 53 | return nil 54 | }, 55 | RunE: func(cmd *cobra.Command, args []string) error { 56 | // First argument is the GitHub user to remove 57 | // Second argument is the filename where to remove the user 58 | err := performRemove(args[0], args[1], remove_requireBackup) 59 | if err != nil { 60 | return err 61 | } 62 | return nil 63 | }, 64 | } 65 | 66 | // Initializes COBRA for this command 67 | func init() { 68 | rootCmd.AddCommand(removeCmd) 69 | 70 | // Add local flag 71 | removeCmd.Flags().BoolVarP(&remove_requireBackup, "backup", "b", true, "Make a backup of the original file") 72 | } 73 | 74 | // Main function of the REMOVE command 75 | func performRemove(githubUser string, fileToClean_name string, isBackup bool) error { 76 | 77 | //Check first if we are dealing with a list of users to exclude. (user string is prefixed with "file:") 78 | exclusionFileSpec := isFileSpec(githubUser) 79 | 80 | if exclusionFileSpec != "" { 81 | var err error 82 | err, excludedGithubUsers = load_exclusions(exclusionFileSpec) 83 | if err != nil { 84 | return fmt.Errorf("invalid excluded user list => %v\n", err) 85 | } 86 | } else { 87 | //We are dealing with the simple syntax (single user on the CMD line) 88 | 89 | //test whether it is a valid GitHub user 90 | if !isValidOrgFormat(githubUser) { 91 | return fmt.Errorf("ERROR: %s is not a valid GitHub user.\n", githubUser) 92 | } else { 93 | excludedGithubUsers = append(excludedGithubUsers, githubUser) 94 | } 95 | } 96 | 97 | //Do we have an existing file to clean ? 98 | if !fileExist(fileToClean_name) { 99 | return fmt.Errorf("ERROR: %s is not an existing file.\n", fileToClean_name) 100 | } 101 | 102 | //Load input file 103 | if isVerbose { 104 | fmt.Printf("Loading the file to clean (%s) \n", fileToClean_name) 105 | } 106 | err, csvToClean_List := loadCSVtoClean(fileToClean_name) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | // Try to clean the file 112 | if isVerbose { 113 | if len(excludedGithubUsers) == 1 { 114 | fmt.Printf("Removing entries for user \"%s\" \n", excludedGithubUsers[0]) 115 | } else { 116 | fmt.Printf("Removing entries for users %s \n", prettyPrintStringList(excludedGithubUsers)) 117 | } 118 | } 119 | cleanedCsv_List := cleanCsvList(csvToClean_List, excludedGithubUsers) 120 | 121 | //Was it useful ? 122 | // cleaned file should be shorter than the initial file 123 | cleanedList_size := len(cleanedCsv_List) 124 | originalList_size := len(csvToClean_List) 125 | if cleanedList_size < originalList_size { 126 | if isBackup { 127 | backupFileName := compute_removeBackupFileName(fileToClean_name) 128 | 129 | if isVerbose { 130 | fmt.Printf("Creating backup file: \"%s\" \n", backupFileName) 131 | } 132 | 133 | //write list with no header and no append 134 | out, _ := openOutputCSV(backupFileName, false, true) 135 | defer out.Close() 136 | writeCSVtoFile(out, false, false, "", csvToClean_List) 137 | out.Close() 138 | } 139 | 140 | if isVerbose { 141 | fmt.Printf("Removed %d lines from \"%s\" and storing... \n", originalList_size-cleanedList_size, fileToClean_name) 142 | } else { 143 | if len(excludedGithubUsers) == 1 { 144 | fmt.Printf("Removed %d line(s) with user \"%s\" from \"%s\"\n", originalList_size-cleanedList_size, excludedGithubUsers[0], fileToClean_name) 145 | } else { 146 | fmt.Printf("Removed %d line(s) with users \"%s\" from \"%s\"\n", originalList_size-cleanedList_size, prettyPrintStringList(excludedGithubUsers), fileToClean_name) 147 | } 148 | } 149 | 150 | //write list with no header and no append 151 | cleanedOut, _ := openOutputCSV(fileToClean_name, false, true) 152 | defer cleanedOut.Close() 153 | writeCSVtoFile(cleanedOut, false, true, "", cleanedCsv_List) 154 | cleanedOut.Close() 155 | } else { 156 | fmt.Printf("Didn't find an entry for user \"%s\" in file \"%s\" \n", githubUser, fileToClean_name) 157 | } 158 | 159 | //If the cleaned file is larger than the original file something went horribly wrong.... 160 | if len(cleanedCsv_List) > len(csvToClean_List) { 161 | return fmt.Errorf("[ERROR] Something went horribly wrong: the cleaned file increased in size !!!!???\n") 162 | } 163 | 164 | return nil 165 | } 166 | 167 | // Check whether the supplied string might be a filespec rather than a user 168 | func isFileSpec(input string) string { 169 | filePrefix_regexp := regexp.MustCompile(`(?i)^file:`) 170 | if filePrefix_regexp.MatchString(input) { 171 | split_result := filePrefix_regexp.Split(input, -1) 172 | return split_result[1] 173 | } 174 | return "" 175 | } 176 | 177 | // load input file 178 | func loadCSVtoClean(fileName string) (error, []string) { 179 | 180 | f, err := os.Open(fileName) 181 | if err != nil { 182 | return fmt.Errorf("Unable to read input file %s: %v\n", fileName, err), nil 183 | } 184 | defer f.Close() 185 | 186 | var loadedFile []string 187 | 188 | scanner := bufio.NewScanner(f) 189 | for scanner.Scan() { 190 | loadedFile = append(loadedFile, scanner.Text()) 191 | } 192 | 193 | if err := scanner.Err(); err != nil { 194 | return fmt.Errorf("Error loading \"%s\": %v", fileName, err), nil 195 | } 196 | 197 | if len(loadedFile) <= 1 { 198 | return fmt.Errorf("Error: \"%s\" seems empty. Retrieved %d lines.", fileName, len(loadedFile)), nil 199 | } 200 | 201 | return nil, loadedFile 202 | } 203 | 204 | // Removes every list item where the gitHub user is present 205 | func cleanCsvList(csvToCleanList []string, githubUserList []string) []string { 206 | var cleanedList []string 207 | 208 | for _, line := range csvToCleanList { 209 | if !listItemContainedInLine(line, githubUserList) { 210 | cleanedList = append(cleanedList, line) 211 | } 212 | } 213 | 214 | return cleanedList 215 | } 216 | 217 | // Returns true if the line contains one of the users in the supplied user list 218 | func listItemContainedInLine(line string, userList []string) bool { 219 | for _, githubUser := range userList { 220 | if strings.Contains(line, githubUser) { 221 | return true 222 | } 223 | } 224 | 225 | return false 226 | } 227 | 228 | // Based on a filename, will return a filename to store the backup 229 | func compute_removeBackupFileName(fileName string) string { 230 | //The validity and existence of the data file are assumed to exist 231 | //Compute the current backup timestamp "YYYYMMDD_HHMMSS" (to be prepend to the original file name) 232 | dt := time.Now() 233 | backupTimeStamp := fmt.Sprint(dt.Format("20060102_150405")) 234 | 235 | // ext := filepath.Ext(fileName) 236 | shortFileName := filepath.Base(fileName) 237 | path := filepath.Dir(fileName) 238 | backup_FileName := fmt.Sprintf("%s/removeBackup_%s__%s", path, backupTimeStamp, shortFileName) 239 | 240 | return (backup_FileName) 241 | } 242 | -------------------------------------------------------------------------------- /cmd/get-commenters-forOnePr.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 | "context" 26 | "fmt" 27 | "log" 28 | "time" 29 | 30 | "github.com/shurcooL/githubv4" 31 | "github.com/spf13/cobra" 32 | "golang.org/x/oauth2" 33 | ) 34 | 35 | // TODO: better variable name 36 | var isDebugGet bool 37 | 38 | // forPrCmd represents the forPr command 39 | var forPrCmd = &cobra.Command{ 40 | Use: "forPr [PR Spec]", 41 | Short: "Retrieves the commenters of a given PR", 42 | Long: `This command will retrieve the commenters of the specified PR from GitHub. 43 | 44 | The PR is specified as "organization/project/PR number". 45 | 46 | The output is a CVS file, specified with the "-o"/"--out" parameter. If not 47 | defined it will take the default output filename. 48 | Each record of the output contains the following information: 49 | - PR specification 50 | - Commenter's login name 51 | - The month the comment was created (YYYY-MM) 52 | 53 | The behavior can be controlled with various flags, such as appending to an existing 54 | output file or overwriting it, header of no-header. 55 | 56 | This query requires authenticated API call. The GitHub Token (Personal Access Token) is 57 | retrieved from an environment variable (default is "GITHUB_TOKEN" but can be overridden with a flag)`, 58 | Args: func(cmd *cobra.Command, args []string) error { 59 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 60 | return err 61 | } 62 | if _, _, _, validateErr := validatePRspec(args[0]); validateErr != nil { 63 | return validateErr 64 | } 65 | 66 | // We probably have a file with users to exclude 67 | if excludeFileName != "" { 68 | var err error 69 | err, excludedGithubUsers = load_exclusions(excludeFileName) 70 | if err != nil { 71 | return fmt.Errorf("invalid excluded user list => %v\n", err) 72 | } 73 | } 74 | 75 | return nil 76 | }, 77 | Run: func(cmd *cobra.Command, args []string) { 78 | initLoggers() 79 | if isRootDebug || isDebugGet { 80 | loggers.debug.Println("******** New debug session ********") 81 | } 82 | 83 | if isRootDebug || isDebugGet { 84 | fmt.Println("*** Debug mode enabled ***\nSee \"debug.log\" for the trace") 85 | } 86 | 87 | getCommenters(args[0], globalIsAppend, globalIsNoHeader, outputFileName) 88 | 89 | }, 90 | } 91 | 92 | func init() { 93 | commentersCmd.AddCommand(forPrCmd) 94 | 95 | getCmd.PersistentFlags().BoolVarP(&isDebugGet, "debugGet", "", false, "Display debug information (super verbose mode) for the GET command") 96 | 97 | err := getCmd.PersistentFlags().MarkHidden("debugGet") 98 | if err != nil { 99 | log.Printf("Error hiding debug flag: %v\n", err) 100 | } 101 | 102 | } 103 | 104 | //********** 105 | // This is where it starts and the magic happens 106 | //********** 107 | 108 | // Get the requested commenter data, extract it, and write it to CSV 109 | func getCommenters(prSpec string, isAppend bool, isNoHeader bool, outputFileName string) int { 110 | 111 | org, prj, pr, err := validatePRspec(prSpec) 112 | if err != nil { 113 | fmt.Printf("Unexpected error in PR specification (%v)\n Skipping %s\n", err, prSpec) 114 | return 0 115 | } 116 | 117 | if isVerbose { 118 | fmt.Printf("Fetching comments for %s\n", prSpec) 119 | } 120 | 121 | if isDebugGet { 122 | loggers.debug.Printf("Fetching comments for %s\n", prSpec) 123 | } 124 | 125 | _, output_data_list := fetchComments_v4(org, prj, pr) 126 | 127 | // Only process if data was found 128 | nbrOfComments := len(output_data_list) 129 | if nbrOfComments > 0 { 130 | 131 | // Creates, overwrites, or opens for append depending on the combination 132 | out, newIsNoHeader := openOutputCSV(outputFileName, isAppend, isNoHeader) 133 | defer out.Close() 134 | 135 | //TODO: Refactor 136 | header := "PR_ref,commenter,month" 137 | writeCSVtoFile(out, isAppend, newIsNoHeader, header, output_data_list) 138 | out.Close() 139 | } else { 140 | if isVerbose { 141 | fmt.Println(" No comments found for PR, skipping...") 142 | } 143 | } 144 | return nbrOfComments 145 | } 146 | 147 | //GitHub Graphql query. Test at https://docs.github.com/en/graphql/overview/explorer 148 | /* 149 | { 150 | rateLimit { 151 | limit 152 | cost 153 | remaining 154 | resetAt 155 | } 156 | repository(name: "flecli", owner: "on4kjm") { 157 | pullRequest(number: 1) { 158 | reviews(first: 100) { 159 | nodes { 160 | bodyText 161 | createdAt 162 | author { 163 | login 164 | } 165 | comments(first: 100) { 166 | nodes { 167 | author { 168 | login 169 | } 170 | body 171 | } 172 | } 173 | } 174 | } 175 | comments(first: 100) { 176 | nodes { 177 | author { 178 | login 179 | } 180 | createdAt 181 | body 182 | } 183 | totalCount 184 | } 185 | } 186 | } 187 | } 188 | */ 189 | 190 | var prQuery2 struct { 191 | Repository struct { 192 | Description string 193 | PullRequest struct { 194 | Title string 195 | Comments struct { 196 | Nodes []struct { 197 | CreatedAt githubv4.DateTime 198 | Body string 199 | Author struct { 200 | Login string 201 | Url string 202 | } 203 | } 204 | } `graphql:"comments(first: 100)"` 205 | Reviews struct { 206 | Nodes []struct { 207 | CreatedAt githubv4.DateTime 208 | BodyText string 209 | Author struct { 210 | Login string 211 | Url string 212 | } 213 | Comments struct { 214 | Nodes []struct { 215 | CreatedAt githubv4.DateTime 216 | Body string 217 | Author struct { 218 | Login string 219 | Url string 220 | } 221 | } 222 | } `graphql:"comments(first: 100)"` 223 | } 224 | } `graphql:"reviews(first: 100)"` 225 | } `graphql:"pullRequest(number: $pr)"` 226 | } `graphql:"repository(owner: $owner, name: $name)"` 227 | RateLimit struct { 228 | Limit int 229 | Cost int 230 | Remaining int 231 | ResetAt time.Time 232 | } 233 | } 234 | 235 | func fetchComments_v4(org string, prj string, pr int) (nbrComment int, output []string) { 236 | // retrieve the token value from the specified environment variable 237 | // ghTokenVar is global and set by the CLI parser 238 | ghToken := loadGitHubToken(ghTokenVar) 239 | src := oauth2.StaticTokenSource( 240 | &oauth2.Token{AccessToken: ghToken}, 241 | ) 242 | httpClient := oauth2.NewClient(context.Background(), src) 243 | client := githubv4.NewClient(httpClient) 244 | 245 | //Check whether we have enough quota left and wait if necessary 246 | // only if we are dealing with a PR list file bigger than the quota limit. 247 | if globalIsBigFile { 248 | checkIfSufficientQuota(5) 249 | } 250 | 251 | variables := map[string]interface{}{ 252 | "owner": githubv4.String(org), 253 | "name": githubv4.String(prj), 254 | "pr": githubv4.Int(pr), 255 | } 256 | 257 | err := client.Query(context.Background(), &prQuery2, variables) 258 | if err != nil { 259 | log.Printf("ERROR: Unexpected error getting comments: %v\n", err) 260 | return 0, nil 261 | } 262 | 263 | prSpec := fmt.Sprintf("%s/%s/%d", org, prj, pr) 264 | totalComments := 0 265 | dbgDateFormat := "2006-01-02 15:04:05" 266 | 267 | var output_slice []string 268 | 269 | for i, comment := range prQuery2.Repository.PullRequest.Comments.Nodes { 270 | 271 | //When there is no info about the user, it means it has been deleted 272 | author := comment.Author.Login 273 | if author == "" { 274 | author = "deleted_user" 275 | } 276 | 277 | // exclude bots 278 | if isUserBot(comment.Author.Url) { 279 | continue 280 | } 281 | 282 | output_slice = append(output_slice, createRecord(prSpec, author, comment.CreatedAt)) 283 | if isDebugGet { 284 | loggers.debug.Printf("%d. %s, %s, \"%s\"\n", i+1, author, comment.CreatedAt.Format(dbgDateFormat), cleanBody(comment.Body)) 285 | } 286 | totalComments++ 287 | } 288 | if isDebugGet { 289 | loggers.debug.Printf("Nbr PR Comments: %d\n", len(prQuery2.Repository.PullRequest.Comments.Nodes)) 290 | } 291 | 292 | for i, comment := range prQuery2.Repository.PullRequest.Reviews.Nodes { 293 | //When there is no info about the user, it means it has been deleted 294 | author := comment.Author.Login 295 | if author == "" { 296 | author = "deleted_user" 297 | } 298 | 299 | // exclude bots 300 | if isUserBot(comment.Author.Url) { 301 | continue 302 | } 303 | 304 | if isDebugGet { 305 | loggers.debug.Printf("%d. %s, %s, \"%s\"\n", i+1, author, comment.CreatedAt.Format(dbgDateFormat), cleanBody(comment.BodyText)) 306 | } 307 | //Just guessing correct counting 308 | if comment.BodyText != "" { 309 | output_slice = append(output_slice, createRecord(prSpec, author, comment.CreatedAt)) 310 | totalComments++ 311 | } 312 | for ii, comment := range comment.Comments.Nodes { 313 | //When there is no info about the user, it means it has been deleted 314 | author := comment.Author.Login 315 | if author == "" { 316 | author = "deleted_user" 317 | } 318 | 319 | // exclude bots 320 | if isUserBot(comment.Author.Url) { 321 | continue 322 | } 323 | 324 | output_slice = append(output_slice, createRecord(prSpec, author, comment.CreatedAt)) 325 | if isDebugGet { 326 | loggers.debug.Printf(" %d. %s %s \"%s\"\n", ii+1, author, 327 | comment.CreatedAt.Format(dbgDateFormat), cleanBody(comment.Body)) 328 | } 329 | totalComments++ 330 | } 331 | } 332 | 333 | prettyPrinted_prSpec := "\"" + prSpec + "\"" 334 | if isRootDebug { 335 | if totalComments == 0 { 336 | loggers.debug.Printf("For %-40s no comment found. (quota cost: %d, remaining: %d)\n", 337 | prettyPrinted_prSpec, prQuery2.RateLimit.Cost, prQuery2.RateLimit.Remaining) 338 | } else { 339 | loggers.debug.Printf("For %-40s found %d comments. (quota cost: %d, remaining: %d)\n", 340 | prettyPrinted_prSpec, totalComments, prQuery2.RateLimit.Cost, prQuery2.RateLimit.Remaining) 341 | } 342 | } 343 | if isDebugGet { 344 | loggers.debug.Printf("Nbr PR Reviews: %d\n", len(prQuery2.Repository.PullRequest.Reviews.Nodes)) 345 | loggers.debug.Printf("Grand total de reviews: %d\n", totalComments) 346 | } 347 | 348 | checkIfSufficientQuota_2(2, prQuery2.RateLimit.Remaining, prQuery2.RateLimit.Limit, prQuery2.RateLimit.ResetAt) 349 | 350 | return totalComments, output_slice 351 | } 352 | 353 | func createRecord(prSpec string, user string, date githubv4.DateTime) string { 354 | monthFormat := "2006-01" 355 | output_record := fmt.Sprintf("\"%s\",\"%s\",\"%s\"", prSpec, user, date.Format(monthFormat)) 356 | return output_record 357 | } 358 | -------------------------------------------------------------------------------- /cmd/get-commenters-forOnePr_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 | "reflect" 26 | "testing" 27 | 28 | "bytes" 29 | "strings" 30 | 31 | "github.com/stretchr/testify/assert" 32 | ) 33 | 34 | // FIXME: this should be an integration test: it requires a defined token and a set of global (default) values 35 | // Worked accidentally on GitHub Action 36 | func Test_getCommenters(t *testing.T) { 37 | type args struct { 38 | prSpec string 39 | isAppend bool 40 | isNoHeader bool 41 | outputFileName string 42 | } 43 | tests := []struct { 44 | name string 45 | args args 46 | }{ 47 | { 48 | "happy case", 49 | args{ 50 | prSpec: "on4kjm/FLEcli/1", 51 | isAppend: false, 52 | isNoHeader: false, 53 | outputFileName: "jenkins_commenters_data.csv", 54 | }, 55 | }, 56 | { 57 | "happy case - append", 58 | args{ 59 | prSpec: "on4kjm/FLEcli/1", 60 | isAppend: true, 61 | isNoHeader: false, 62 | outputFileName: "jenkins_commenters_data.csv", 63 | }, 64 | }, 65 | { 66 | "ghost user", 67 | args{ 68 | prSpec: "jenkinsci/aqua-security-scanner-plugin/51", 69 | isAppend: true, 70 | isNoHeader: false, 71 | outputFileName: "jenkins_commenters_data.csv", 72 | }, 73 | }, 74 | } 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | getCommenters(tt.args.prSpec, tt.args.isAppend, tt.args.isNoHeader, tt.args.outputFileName) 78 | }) 79 | } 80 | } 81 | 82 | // https://github.com/on4kjm/flecli/pull/1 83 | var testResult1 = []string{ 84 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 85 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 86 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 87 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 88 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 89 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 90 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 91 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 92 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 93 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 94 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 95 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 96 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 97 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 98 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 99 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 100 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 101 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 102 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 103 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 104 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 105 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 106 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 107 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 108 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 109 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 110 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 111 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 112 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 113 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 114 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 115 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 116 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 117 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 118 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 119 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 120 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 121 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 122 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 123 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 124 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 125 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 126 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 127 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 128 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 129 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 130 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 131 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 132 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 133 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 134 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 135 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 136 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 137 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 138 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 139 | "\"on4kjm/flecli/1\",\"jmMeessen\",\"2020-07\"", 140 | "\"on4kjm/flecli/1\",\"jlevesy\",\"2020-07\"", 141 | } 142 | 143 | // https://github.com/jenkinsci/aqua-security-scanner-plugin/pull/51 144 | var testResult2 = []string{ 145 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 146 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 147 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 148 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 149 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"rajinikanthj\",\"2023-06\"", 150 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"rajinikanthj\",\"2023-06\"", 151 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 152 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 153 | "\"jenkinsci/aqua-security-scanner-plugin/51\",\"deleted_user\",\"2023-06\"", 154 | } 155 | 156 | // https://github.com/jenkins-infra/helm-charts/pull/586 157 | var testResult3 = []string{ 158 | "\"jenkins-infra/helm-charts/586\",\"lemeurherve\",\"2023-08\"", 159 | "\"jenkins-infra/helm-charts/586\",\"lemeurherve\",\"2023-08\"", 160 | "\"jenkins-infra/helm-charts/586\",\"dduportal\",\"2023-08\"", 161 | } 162 | 163 | // https://github.com/jenkinsci/build-blocker-plugin/pull/19 164 | var testResult4 = []string{ 165 | "\"jenkinsci/build-blocker-plugin/19\",\"olamy\",\"2023-08\"", 166 | "\"jenkinsci/build-blocker-plugin/19\",\"jglick\",\"2023-08\"", 167 | "\"jenkinsci/build-blocker-plugin/19\",\"olamy\",\"2023-08\"", 168 | "\"jenkinsci/build-blocker-plugin/19\",\"jglick\",\"2023-08\"", 169 | "\"jenkinsci/build-blocker-plugin/19\",\"jonesbusy\",\"2023-08\"", 170 | "\"jenkinsci/build-blocker-plugin/19\",\"olamy\",\"2023-09\"", 171 | "\"jenkinsci/build-blocker-plugin/19\",\"Denis1990\",\"2023-09\"", 172 | "\"jenkinsci/build-blocker-plugin/19\",\"Denis1990\",\"2023-09\"", 173 | "\"jenkinsci/build-blocker-plugin/19\",\"jglick\",\"2023-08\"", 174 | } 175 | 176 | // https://github.com/jenkinsci/credentials-plugin/pull/475 177 | var testResult5 = []string{ 178 | "\"jenkinsci/credentials-plugin/475\",\"jtnord\",\"2023-09\"", 179 | } 180 | 181 | // bot test 182 | var testResult6 = []string{ 183 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 184 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 185 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 186 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 187 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 188 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 189 | "\"jenkinsci/blueocean-plugin/2050\",\"dwnusbaum\",\"2020-02\"", 190 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-02\"", 191 | "\"jenkinsci/blueocean-plugin/2050\",\"NicuPascu\",\"2020-02\"", 192 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-03\"", 193 | "\"jenkinsci/blueocean-plugin/2050\",\"bitwiseman\",\"2020-04\"", 194 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 195 | "\"jenkinsci/blueocean-plugin/2050\",\"bitwiseman\",\"2020-04\"", 196 | "\"jenkinsci/blueocean-plugin/2050\",\"olamy\",\"2020-04\"", 197 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 198 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-05\"", 199 | "\"jenkinsci/blueocean-plugin/2050\",\"bitwiseman\",\"2020-04\"", 200 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 201 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 202 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 203 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 204 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 205 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 206 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 207 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 208 | "\"jenkinsci/blueocean-plugin/2050\",\"stuartrowe\",\"2020-04\"", 209 | } 210 | 211 | func Test_fetchComments_alt(t *testing.T) { 212 | type args struct { 213 | org string 214 | prj string 215 | pr int 216 | } 217 | tests := []struct { 218 | name string 219 | args args 220 | wantNbrComment int 221 | wantOutput []string 222 | }{ 223 | { 224 | "first test", 225 | args{ 226 | org: "on4kjm", 227 | prj: "flecli", 228 | pr: 1, 229 | }, 230 | 57, testResult1, 231 | }, 232 | { 233 | "PR with deleted user", 234 | args{ 235 | org: "jenkinsci", 236 | prj: "aqua-security-scanner-plugin", 237 | pr: 51, 238 | }, 239 | 9, testResult2, 240 | }, 241 | { 242 | "PR with bot user", 243 | args{ 244 | org: "jenkinsci", 245 | prj: "blueocean-plugin", 246 | pr: 2050, 247 | }, 248 | 26, testResult6, 249 | }, 250 | // jenkins-infra/helm-charts/pull/586 251 | { 252 | "random PR", 253 | args{ 254 | org: "jenkins-infra", 255 | prj: "helm-charts", 256 | pr: 586, 257 | }, 258 | 3, testResult3, 259 | }, 260 | //https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229 261 | { 262 | "PR with no comments", 263 | args{ 264 | org: "jenkinsci", 265 | prj: "embeddable-build-status-plugin", 266 | pr: 229, 267 | }, 268 | 0, nil, 269 | }, 270 | // https://github.com/jenkinsci/build-blocker-plugin/pull/19 271 | { 272 | "Random PR #2", 273 | args{ 274 | org: "jenkinsci", 275 | prj: "build-blocker-plugin", 276 | pr: 19, 277 | }, 278 | 9, testResult4, 279 | }, 280 | //https://github.com/jenkinsci/credentials-plugin/pull/475 281 | { 282 | "Random PR #3", 283 | args{ 284 | org: "jenkinsci", 285 | prj: "credentials-plugin", 286 | pr: 475, 287 | }, 288 | 1, testResult5, 289 | }, 290 | // unexisting PR 291 | { 292 | "no PR to see here", 293 | args{ 294 | org: "on4kjm", 295 | prj: "flecli", 296 | pr: 4, 297 | }, 298 | 0, nil, 299 | }, 300 | } 301 | for _, tt := range tests { 302 | t.Run(tt.name, func(t *testing.T) { 303 | gotNbrComment, gotOutput := fetchComments_v4(tt.args.org, tt.args.prj, tt.args.pr) 304 | if gotNbrComment != tt.wantNbrComment { 305 | t.Errorf("fetchComments_alt() gotNbrComment = %v, want %v", gotNbrComment, tt.wantNbrComment) 306 | } 307 | if !reflect.DeepEqual(gotOutput, tt.wantOutput) { 308 | t.Errorf("fetchComments_alt() gotOutput = %v, want %v", gotOutput, tt.wantOutput) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | func Test_ExecuteGetCommenterSinglePrProcessExcludeIfPresent(t *testing.T) { 315 | actual := new(bytes.Buffer) 316 | rootCmd.SetOut(actual) 317 | rootCmd.SetErr(actual) 318 | rootCmd.SetArgs([]string{"get", "commenters", "forPr", "jenkinsci/credentials-plugin/475", "-x", "nonExistingFile.txt"}) 319 | error := rootCmd.Execute() 320 | 321 | assert.Error(t, error, "Function call should have failed") 322 | 323 | //Error is expected 324 | expectedMsg := "Error: invalid excluded user list => Unable to read input file nonExistingFile.txt: open nonExistingFile.txt: no such file or directory" 325 | lines := strings.Split(actual.String(), "\n") 326 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 327 | } 328 | -------------------------------------------------------------------------------- /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 | package cmd 23 | 24 | import ( 25 | "bufio" 26 | "fmt" 27 | "log" 28 | "os" 29 | "regexp" 30 | "strconv" 31 | "strings" 32 | "time" 33 | "unicode" 34 | ) 35 | 36 | // validates that the supplied string is a valid PR specification 37 | // in the form of "org/project/pr_nbr" 38 | func validatePRspec(prSpec string) (org string, project string, prNbr int, err error) { 39 | splittedString := strings.Split(strings.TrimSpace(prSpec), "/") 40 | 41 | if len(splittedString) != 3 { 42 | return "", "", -1, fmt.Errorf("Invalid number of elements in PR Specification (\"org/project/pr\"). (expecting 3, found %v)\n", len(splittedString)) 43 | } 44 | 45 | work_Org := splittedString[0] 46 | work_Project := splittedString[1] 47 | prString := splittedString[2] 48 | 49 | if strings.TrimSpace(work_Org) == "" { 50 | return "", "", -1, fmt.Errorf("Organization element in PR Specification is empty\n") 51 | } 52 | if strings.TrimSpace(work_Project) == "" { 53 | return "", "", -1, fmt.Errorf("Project element in PR Specification is empty\n") 54 | } 55 | if strings.TrimSpace(prString) == "" { 56 | return "", "", -1, fmt.Errorf("PR element in PR Specification is empty\n") 57 | } 58 | 59 | work_prNbr, err := strconv.Atoi(strings.TrimSpace(prString)) 60 | if err != nil { 61 | return "", "", -1, fmt.Errorf("PR part of PR Specification is not numerical (%v)\n", err) 62 | } 63 | return work_Org, work_Project, work_prNbr, nil 64 | } 65 | 66 | // Write the string slice to a file formatted as a CSV 67 | func writeCSVtoFile(out *os.File, isAppend bool, isNoHeader bool, header string, csv_output_slice []string) { 68 | 69 | localIsNoHeader := isNoHeader 70 | 71 | datawriter := bufio.NewWriter(out) 72 | 73 | // Add the CSV header record, unless explicitly asked not to add it 74 | if !localIsNoHeader { 75 | _, headerWriteError := datawriter.WriteString(header + "\n") 76 | if headerWriteError != nil { 77 | log.Fatal(headerWriteError) 78 | } 79 | datawriter.Flush() 80 | } 81 | 82 | // write all the records in memory 83 | for _, data := range csv_output_slice { 84 | _, _ = datawriter.WriteString(data + "\n") 85 | } 86 | 87 | datawriter.Flush() 88 | } 89 | 90 | // creates or opens for append (if the file exists) the output file 91 | // If no append is requested and the file exists, it is overwritten 92 | func openOutputCSV(outFname string, isAppend bool, isNoHeader bool) (*os.File, bool) { 93 | 94 | isExisting := fileExist(outputFileName) 95 | localIsNoHeader := isNoHeader 96 | 97 | var isAppendString string 98 | isNoHeaderString := "without" 99 | if !localIsNoHeader { 100 | isNoHeaderString = "with" 101 | } 102 | 103 | var out *os.File 104 | var open_error error 105 | 106 | if isExisting { 107 | if isAppend { 108 | // Open for append 109 | out, open_error = os.OpenFile(outFname, os.O_APPEND|os.O_WRONLY, 0644) 110 | if open_error != nil { 111 | log.Fatal(open_error) 112 | } 113 | 114 | isAppendString = "(appending" 115 | // no Header forced 116 | isNoHeaderString = "without" 117 | localIsNoHeader = true 118 | } else { 119 | // overwrite output file 120 | out, open_error = os.Create(outFname) 121 | if open_error != nil { 122 | log.Fatal(open_error) 123 | } 124 | isAppendString = "(overwriting" 125 | // honor the noheader setting 126 | } 127 | } else { 128 | //create output file 129 | out, open_error = os.Create(outFname) 130 | if open_error != nil { 131 | log.Fatal(open_error) 132 | } 133 | isAppendString = "(creating" 134 | // honor noHeader setting 135 | } 136 | 137 | if isVerbose { 138 | fmt.Printf("Writing data to \"%s\" %s %s header)\n", outFname, isAppendString, isNoHeaderString) 139 | } 140 | 141 | return out, localIsNoHeader 142 | } 143 | 144 | // Validates that the input file is a real file (and not a directory) 145 | func fileExist(fileName string) bool { 146 | info, err := os.Stat(fileName) 147 | if os.IsNotExist(err) { 148 | return false 149 | } 150 | return !info.IsDir() 151 | } 152 | 153 | // Load the GitHub token from the specified environment variable 154 | func loadGitHubToken(envVariableName string) string { 155 | token, found := os.LookupEnv(envVariableName) 156 | if !found { 157 | fmt.Println("Unauthorized: No token present") 158 | //This is a major error: we crash out of the program 159 | log.Fatal("GitHub token not found!") 160 | } 161 | return token 162 | } 163 | 164 | // Removes and truncates a Body or BodyText element 165 | func cleanBody(input string) (output string) { 166 | re := regexp.MustCompile(`\r?\n`) 167 | temp := re.ReplaceAllString(input, " ") 168 | 169 | re2 := regexp.MustCompile(`\"`) 170 | temp2 := re2.ReplaceAllString(temp, "'") 171 | 172 | output = truncateString(temp2, 40) 173 | return output 174 | } 175 | 176 | func truncateString(input string, max int) (otput string) { 177 | lastSpaceIx := -1 178 | len := 0 179 | for i, r := range input { 180 | if unicode.IsSpace(r) { 181 | lastSpaceIx = i 182 | } 183 | len++ 184 | if len >= max { 185 | if lastSpaceIx != -1 { 186 | return input[:lastSpaceIx] + "..." 187 | } 188 | // If here, string is longer than max, but has no spaces 189 | } 190 | } 191 | // If here, string is shorter than max 192 | return input 193 | } 194 | 195 | // Checks whether the input is a month in the expected format 196 | func isValidMonthFormat(input string) bool { 197 | if input == "" { 198 | if isVerbose { 199 | fmt.Print("Empty month\n") 200 | } 201 | return false 202 | } 203 | 204 | regexpMonth := regexp.MustCompile(`^20[12][0-9]-(0[1-9]|1[0-2])$`) 205 | if !regexpMonth.MatchString(input) { 206 | if isVerbose { 207 | fmt.Printf("Supplied data (%s) is not in a valid month format. Should be \"YYYY-MM\" and later than 2010\n", input) 208 | } 209 | return false 210 | } 211 | return true 212 | } 213 | 214 | // Checks if an author is in the list of authors to exclude. 215 | // This function assumes that supplied data has been checked upstream. 216 | func isExcludedAuthor(authorList []string, authorToCheck string) bool { 217 | for _, author := range authorList { 218 | if strings.EqualFold(author, authorToCheck) { 219 | return true 220 | } 221 | } 222 | return false 223 | } 224 | 225 | // Pretty prints the content of a string slice (ex: excluded GitHub users) 226 | func prettyPrintStringList(listToPrint []string) string { 227 | var outputString string 228 | 229 | if len(listToPrint) == 0 { 230 | return "[ (empty) ]" 231 | } 232 | 233 | for index, j := range listToPrint { 234 | if index == 0 { //If the value is first one 235 | if len(listToPrint) == 1 { 236 | //We have a single element list 237 | outputString = outputString + fmt.Sprintf("[ '%v' ]", j) 238 | } else { 239 | outputString = outputString + fmt.Sprintf("[ '%v', ", j) 240 | } 241 | 242 | } else if len(listToPrint) == index+1 { // If the value is the last one 243 | outputString = outputString + fmt.Sprintf("'%v' ]", j) 244 | } else { 245 | outputString = outputString + fmt.Sprintf(" '%v', ", j) // for all ( middle ) values 246 | } 247 | } 248 | return outputString 249 | } 250 | 251 | // Validates whether the input is correctly formatted as a GitHub user or organization 252 | func isValidOrgFormat(input string) bool { 253 | if input == "" { 254 | if isVerbose { 255 | fmt.Print("Empty Org\n") 256 | } 257 | return false 258 | } 259 | 260 | //The GitHub user validation regexp (see https://stackoverflow.com/questions/58726546/github-username-convention-using-regex) 261 | // should be regexp.Compile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`). But the dataset contains "invalid" data: username ending with a "-" or 262 | // a double "-" in the name. 263 | name_regexp := regexp.MustCompile(`^[a-zA-Z0-9\-]+$`) 264 | if !name_regexp.MatchString(input) { 265 | if isVerbose { 266 | fmt.Printf("Supplied data (%s) is not in a valid GitHub user/org format.\n", input) 267 | } 268 | return false 269 | } 270 | 271 | return true 272 | } 273 | 274 | // checks whether the user is an application based on the URL 275 | func isUserBot(url string) bool { 276 | if strings.HasPrefix(strings.ToLower(url), "https://github.com/apps/") { 277 | return true 278 | } else { 279 | return false 280 | } 281 | } 282 | 283 | // Computes the start and end date based on the total number of issues returned by query 284 | func splitPeriodForMaxQueryItem(totalNbrIssue int, shortMonth string, requestedIteration int) (startDate string, endDate string, moreIteration bool) { 285 | queryLimit := 1000 // constant 286 | 287 | // Parameters validation 288 | if requestedIteration < 0 { 289 | if isRootDebug { 290 | loggers.debug.Printf("Error: requested iteration (%d) is negative\n", requestedIteration) 291 | } 292 | return "", "", false 293 | } 294 | 295 | if totalNbrIssue < 0 { 296 | if isRootDebug { 297 | loggers.debug.Printf("Error: total number of issues (%d) is negative\n", totalNbrIssue) 298 | } 299 | return "", "", false 300 | } 301 | 302 | if totalNbrIssue > (28 * queryLimit) { 303 | if isRootDebug { 304 | loggers.debug.Printf("Error: requested iteration (%d) is greater than what we can handle (28 * %d)\n", requestedIteration, queryLimit) 305 | } 306 | return "", "", false 307 | } 308 | 309 | //load short month in a time structure and implicitly validate it 310 | inputDate, err := time.Parse("2006-01", shortMonth) 311 | if err != nil { 312 | if isRootDebug { 313 | loggers.debug.Printf("Unexpected error parsing short month (%v)\n", err) 314 | } 315 | return "", "", false 316 | } 317 | 318 | // *** Let's go **** 319 | 320 | //retrieve the year and month in time structure 321 | inputYear, inputMonth, _ := inputDate.Date() 322 | 323 | // Get the first and last day of the month we are looking at 324 | currentLocation := inputDate.Location() 325 | firstOfMonth := time.Date(inputYear, inputMonth, 1, 0, 0, 0, 0, currentLocation) 326 | lastOfMonth := firstOfMonth.AddDate(0, 1, -1) 327 | 328 | if totalNbrIssue < queryLimit { 329 | // we convert the time structs to strings 330 | startDate = firstOfMonth.Format("2006-01-02") 331 | endDate = lastOfMonth.Format("2006-01-02") 332 | moreIteration = false 333 | 334 | //There must be a single iteration 335 | if requestedIteration != 0 { 336 | if isRootDebug { 337 | loggers.debug.Printf("Unexpected error: iteration number (%d) should be equal to 0\n", requestedIteration) 338 | } 339 | return "", "", false 340 | } 341 | } else { 342 | // compute the total number of iteration required 343 | totalIterations := int(totalNbrIssue/queryLimit) + 1 344 | if requestedIteration > totalIterations { 345 | if isRootDebug { 346 | loggers.debug.Printf("Error: requested iteration (%d) is greater than the total number of iteration (%d)\n", requestedIteration, totalIterations) 347 | } 348 | return "", "", false 349 | } 350 | numberOfDaysInMonth := lastOfMonth.Day() 351 | daysPerIterations := int(numberOfDaysInMonth / totalIterations) 352 | 353 | //compute the iteration start date 354 | iterationStartDay := (daysPerIterations * requestedIteration) + 1 355 | startOfIterationDate := time.Date(inputYear, inputMonth, iterationStartDay, 0, 0, 0, 0, currentLocation) 356 | startDate = startOfIterationDate.Format("2006-01-02") 357 | 358 | //compute the iteration end date 359 | iterationEndDay := daysPerIterations + (daysPerIterations * requestedIteration) 360 | endOfIterationDate := time.Date(inputYear, inputMonth, iterationEndDay, 0, 0, 0, 0, currentLocation) 361 | endDate = endOfIterationDate.Format("2006-01-02") 362 | 363 | //did we reach the last iteration? 364 | if (requestedIteration + 1) == totalIterations { 365 | moreIteration = false 366 | // in the last iteration, we catch up any rounding errors by forcing the months's last day 367 | endOfIterationDate := time.Date(inputYear, inputMonth, numberOfDaysInMonth, 0, 0, 0, 0, currentLocation) 368 | endDate = endOfIterationDate.Format("2006-01-02") 369 | } else { 370 | moreIteration = true 371 | } 372 | 373 | } 374 | 375 | if isRootDebug { 376 | loggers.debug.Printf("Iteration start: %s end: %s has more iterations: %v\n", startDate, endDate, moreIteration) 377 | } 378 | return startDate, endDate, moreIteration 379 | } 380 | 381 | // returns the start and end day for a given month (YYYY-MM) 382 | func getStartAndEndOfMonth(shortMonth string) (startDate string, endDate string) { 383 | //load short month in a time structure 384 | inputDate, _ := time.Parse("2006-01", shortMonth) 385 | 386 | //retrieve the year and month in time structure 387 | inputYear, inputMonth, _ := inputDate.Date() 388 | 389 | //Build the dates we are looking for 390 | currentLocation := inputDate.Location() 391 | firstOfMonth := time.Date(inputYear, inputMonth, 1, 0, 0, 0, 0, currentLocation) 392 | lastOfMonth := firstOfMonth.AddDate(0, 1, -1) 393 | 394 | //convert first and last days into a string in the expected format 395 | firstOfMonthString := firstOfMonth.Format("2006-01-02") 396 | lastOfMonthString := lastOfMonth.Format("2006-01-02") 397 | 398 | return firstOfMonthString, lastOfMonthString 399 | } 400 | 401 | // TODO: test this 402 | // Checks whether the retrieved header is equivalent to the reference header 403 | func validateHeader(header []string, referenceHeader []string, isVerbose bool) bool { 404 | if len(header) != len(referenceHeader) { 405 | if isVerbose { 406 | fmt.Printf(" Error: field number mismatch (found %d, wanted %d)\n", len(header), len(referenceHeader)) 407 | } 408 | return false 409 | } 410 | for i, v := range header { 411 | if v != referenceHeader[i] { 412 | if isVerbose { 413 | fmt.Printf(" Error: not the expected header field at column %d (found \"%v\", wanted \"%v\")\n", i+1, v, referenceHeader[i]) 414 | } 415 | return false 416 | } 417 | } 418 | return true 419 | } 420 | 421 | // Validates that the spec is a directory that exists 422 | func isValidDir(dirName string) bool { 423 | info, err := os.Stat(dirName) 424 | if os.IsNotExist(err) { 425 | return false 426 | } 427 | return info.IsDir() 428 | } 429 | -------------------------------------------------------------------------------- /cmd/honor.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 | "context" 26 | "encoding/csv" 27 | "fmt" 28 | "math/rand/v2" 29 | "os" 30 | "path/filepath" 31 | "strconv" 32 | "strings" 33 | "time" 34 | 35 | "github.com/shurcooL/githubv4" 36 | "github.com/spf13/cobra" 37 | "golang.org/x/oauth2" 38 | ) 39 | 40 | var honorDataDir string 41 | var honorOutput string 42 | 43 | type HonoredContributorData struct { 44 | handle string 45 | fullName string 46 | authorURL string 47 | authorAvatarUrl string 48 | authorCompany string 49 | month string 50 | totalPRs_found string 51 | totalPRs_expected string 52 | repositories string 53 | } 54 | 55 | // honorCmd represents the honor command 56 | var honorCmd = &cobra.Command{ 57 | Use: "honor ", 58 | Short: "Gets a contributor to honor", 59 | Long: `A command to get a random submitter from a given month and 60 | format his data in such a way that it can be used to format an honoring 61 | message at the bottom of the https://contributors.jenkins.io/ page. 62 | 63 | \"month\" is a required parameter. It is in YYYY-MM format.`, 64 | Args: func(cmd *cobra.Command, args []string) error { 65 | //call requires two parameters (org and month) 66 | if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { 67 | if err.Error() == "requires at least 1 arg(s), only received 0" { 68 | return fmt.Errorf("\"month\" argument is missing.") 69 | } else { 70 | return err 71 | } 72 | } 73 | return nil 74 | }, 75 | RunE: func(cmd *cobra.Command, args []string) error { 76 | return performHonorContributorSelection(honorDataDir, honorOutput, args[0]) 77 | }, 78 | } 79 | 80 | // Initialize command parameters and defaults 81 | func init() { 82 | rootCmd.AddCommand(honorCmd) 83 | honorCmd.Flags().StringVarP(&honorDataDir, "data_dir", "", "data", "Directory containing the data to be read") 84 | honorCmd.Flags().StringVarP(&honorOutput, "output", "", "", "File to output the data to (default: \"[data_dir]/honored_contributor.csv\")") 85 | } 86 | 87 | // Command processing entry point 88 | func performHonorContributorSelection(dataDir string, suppliedOutputFileName string, monthToSelectFrom string) error { 89 | // validate the month 90 | if !isValidMonthFormat(monthToSelectFrom) { 91 | return fmt.Errorf("\"%s\" is not a valid month.", monthToSelectFrom) 92 | } 93 | 94 | // does the dataDir exist ? 95 | if !isValidDir(dataDir) { 96 | return fmt.Errorf("Supplied DataDir \"%s\" does not exist.", dataDir) 97 | } 98 | 99 | // if output is not defined, build it 100 | honorOutputFileName := "" 101 | if suppliedOutputFileName == "" { 102 | honorOutputFileName = filepath.Join(dataDir, "honored_contributor.csv") 103 | } else { 104 | honorOutputFileName = suppliedOutputFileName 105 | } 106 | if isVerbose { 107 | fmt.Println("Output file: " + honorOutputFileName + "\n") 108 | } 109 | 110 | //compute the correct input filename (pr_per_submitter-YYYY-MM.csv) 111 | inputFileName := filepath.Join(dataDir, "pr_per_submitter-"+monthToSelectFrom+".csv") 112 | 113 | // fail if the file does not exist else open the file 114 | f, err := os.Open(inputFileName) 115 | if err != nil { 116 | return fmt.Errorf("Unable to read input file "+inputFileName+"\n", err) 117 | } 118 | defer f.Close() 119 | 120 | // validate that it has the correct format (CSV and column names) 121 | r := csv.NewReader(f) 122 | 123 | headerLine, err1 := r.Read() 124 | if err1 != nil { 125 | return fmt.Errorf("Unexpected error loading"+inputFileName+"\n", err) 126 | } 127 | 128 | if isVerbose { 129 | fmt.Println("Checking input file " + inputFileName) 130 | } 131 | 132 | referencePrPerSubmitterHeader := []string{"user", "PR"} 133 | if !validateHeader(headerLine, referencePrPerSubmitterHeader, isVerbose) { 134 | return fmt.Errorf(" Error: header is incorrect.") 135 | } else { 136 | if isVerbose { 137 | fmt.Printf(" - Header is correct\n") 138 | } 139 | } 140 | 141 | // load the file in memory 142 | records, err := r.ReadAll() 143 | if err != nil { 144 | return fmt.Errorf("Unexpected error loading \""+inputFileName+"\"\n", err) 145 | } 146 | 147 | if len(records) < 1 { 148 | return fmt.Errorf("Error: No data available after the header\n") 149 | } 150 | if isVerbose { 151 | fmt.Println(" - At least one Submitter data available") 152 | } 153 | 154 | // pick a data line randomly 155 | nbrOfRecordsLoaded := len(records) - 1 156 | 157 | randomRecordNumber := rand.IntN(nbrOfRecordsLoaded) 158 | submittersName := records[randomRecordNumber][0] 159 | submittersPRs := records[randomRecordNumber][1] 160 | // fmt.Printf("[%d] - %s - %s PRs\n", randomRecordNumber, records[randomRecordNumber][0], records[randomRecordNumber][1]) 161 | if isVerbose { 162 | fmt.Printf(" - Picked record %d : %s - %s PRs\n", randomRecordNumber, submittersName, submittersPRs) 163 | } 164 | 165 | // make a GitHub query to retrieve the contributors information (URL, avatar) and PRs 166 | if isVerbose { 167 | fmt.Printf("Fetching data from GitHub") 168 | } 169 | 170 | var contributorData HonoredContributorData 171 | if err, contributorData = getSubmittersPRfromGH(submittersName, submittersPRs, monthToSelectFrom); err != nil { 172 | return err 173 | } 174 | 175 | // format the output with the gathered data 176 | var honorCSVlist []string 177 | workBuffer1 := generateHonoredContributorDataCSVheader() 178 | workBuffer2 := fmt.Sprintf("\"%s\", ", getCurrentTimeAsTimeStamp("")) + generateHonoredContributorDataAsCSV(contributorData) 179 | honorCSVlist = append(honorCSVlist, workBuffer1, workBuffer2) 180 | 181 | // output the file 182 | 183 | // Creates, overwrites the output file (no append and with no header generation) 184 | out, _ := openOutputCSV(honorOutputFileName, false, true) 185 | defer out.Close() 186 | 187 | writeCSVtoFile(out, false, true, "", honorCSVlist) 188 | out.Close() 189 | 190 | return nil 191 | } 192 | 193 | var uniqueRepoSet = make(map[string]bool) 194 | var uniqueRepoSlice = []string{} 195 | 196 | // Adds an item to the slice only if it is not there yet. See https://stackoverflow.com/questions/33207197/how-can-i-create-an-array-that-contains-unique-strings 197 | func addUniqueItem(s string) { 198 | if uniqueRepoSet[s] { 199 | return // Already in the map 200 | } 201 | uniqueRepoSlice = append(uniqueRepoSlice, s) 202 | uniqueRepoSet[s] = true 203 | } 204 | 205 | /***** 206 | ***** Github query definition 207 | *****/ 208 | 209 | //GitHub Graphql query. Test at https://docs.github.com/en/graphql/overview/explorer 210 | /* 211 | { 212 | user(login: "basil"){ 213 | name 214 | company 215 | avatarUrl 216 | url 217 | } 218 | search(query: "org:jenkinsci org:jenkins-infra is:pr author:dduportal created:2024-04-01..2024-04-30", type: ISSUE, first: 100) { 219 | issueCount 220 | edges { 221 | node { 222 | ... on PullRequest { 223 | author { 224 | login 225 | avatarUrl 226 | url 227 | } 228 | url 229 | title 230 | createdAt 231 | repository { 232 | name 233 | } 234 | } 235 | } 236 | } 237 | } 238 | } 239 | */ 240 | 241 | //****************************** 242 | 243 | // Gets all the PRs in the given month for the submitters 244 | func getSubmittersPRfromGH(submittersName string, submittersPRs string, monthToSelectFrom string) (error, HonoredContributorData) { 245 | 246 | // Setup the GH query client 247 | ghToken := loadGitHubToken(ghTokenVar) 248 | src := oauth2.StaticTokenSource( 249 | &oauth2.Token{AccessToken: ghToken}, 250 | ) 251 | httpClient := oauth2.NewClient(context.Background(), src) 252 | client := githubv4.NewClient(httpClient) 253 | 254 | var contributorData HonoredContributorData 255 | contributorData.handle = submittersName 256 | contributorData.totalPRs_expected = submittersPRs 257 | contributorData.month = monthToSelectFrom 258 | 259 | // Setup the query to retrieve the user's information 260 | var userQuery struct { 261 | User struct { 262 | Login string 263 | Name string 264 | Company string 265 | AvatarUrl string 266 | Url string 267 | } `graphql:"user(login: $submitter)"` 268 | } 269 | userVariables := map[string]interface{}{ 270 | "submitter": githubv4.String(submittersName), 271 | } 272 | if err := client.Query(context.Background(), &userQuery, userVariables); err != nil { 273 | return fmt.Errorf("Error performing user query: %v\n", err), contributorData 274 | } 275 | 276 | // Load the data form the returned json file 277 | contributorData.handle = submittersName 278 | contributorData.fullName = userQuery.User.Name 279 | contributorData.authorURL = userQuery.User.Url 280 | contributorData.authorAvatarUrl = userQuery.User.AvatarUrl 281 | contributorData.authorCompany = userQuery.User.Company 282 | 283 | // Setup the GH call to retrieve all the contributions 284 | startDate, endDate := getStartAndEndOfMonth(monthToSelectFrom) 285 | var prQuery3 struct { 286 | Search struct { 287 | IssueCount int 288 | Edges []struct { 289 | Node struct { 290 | PullRequest struct { 291 | Url string 292 | Title string 293 | CreatedAt time.Time 294 | Repository struct { 295 | Name string 296 | Owner struct { 297 | Login string 298 | } 299 | } 300 | Author struct { 301 | Login string 302 | } 303 | } `graphql:"... on PullRequest"` 304 | } 305 | } 306 | } `graphql:"search(first: $count, query: $searchQuery, type: ISSUE)"` 307 | } 308 | 309 | variables := map[string]interface{}{ 310 | "searchQuery": githubv4.String( 311 | fmt.Sprintf(`org:%s org:%s org:%s is:pr author:%s created:%s..%s`, 312 | githubv4.String("jenkinsci"), 313 | githubv4.String("jenkins-infra"), 314 | githubv4.String("jenkins-docs"), 315 | githubv4.String(submittersName), 316 | githubv4.String(startDate), 317 | githubv4.String(endDate), 318 | ), 319 | ), 320 | "count": githubv4.Int(100), 321 | } 322 | 323 | if err := client.Query(context.Background(), &prQuery3, variables); err != nil { 324 | return fmt.Errorf("Error performing PR query: %v\n", err), contributorData 325 | } 326 | 327 | totalPRs := prQuery3.Search.IssueCount 328 | contributorData.totalPRs_found = strconv.Itoa(totalPRs) 329 | if contributorData.totalPRs_expected != contributorData.totalPRs_found { 330 | return fmt.Errorf("Expected PR number does not match query's PR number. (%s vs. %s)", contributorData.totalPRs_expected, contributorData.totalPRs_found), contributorData 331 | } 332 | 333 | for _, singlePr := range prQuery3.Search.Edges { 334 | if singlePr.Node.PullRequest.Author.Login != submittersName { 335 | return fmt.Errorf("Unexpected error: PR author does not match requested GH userName (%s vs. %s)", singlePr.Node.PullRequest.Author.Login, submittersName), contributorData 336 | } 337 | repositoryName := singlePr.Node.PullRequest.Repository.Owner.Login + "/" + singlePr.Node.PullRequest.Repository.Name 338 | addUniqueItem(repositoryName) 339 | } 340 | 341 | // takes the slice and generates a string with items separated by spaces 342 | contributorData.repositories = stringifySlice(uniqueRepoSlice) 343 | 344 | if isVerbose { 345 | fmt.Print("\n\n") 346 | fmt.Println(prettyPrint_HonoredContributorData(contributorData)) 347 | } 348 | 349 | return nil, contributorData 350 | } 351 | 352 | // Format the data in a displayable manner 353 | func prettyPrint_HonoredContributorData(data HonoredContributorData) string { 354 | var strBuffer strings.Builder 355 | strBuffer.WriteString(fmt.Sprintf("PRs found: %s\n", data.totalPRs_found)) 356 | strBuffer.WriteString(fmt.Sprintf("PRs expected: %s\n", data.totalPRs_expected)) 357 | strBuffer.WriteString(fmt.Sprintf("Month: %s\n", data.month)) 358 | strBuffer.WriteString(fmt.Sprintf("Repositories: %s\n\n", data.repositories)) 359 | strBuffer.WriteString(fmt.Sprintf("GH handle: %s\n", data.handle)) 360 | strBuffer.WriteString(fmt.Sprintf("User name: %s\n", data.fullName)) 361 | strBuffer.WriteString(fmt.Sprintf("URL: %s\n", data.authorURL)) 362 | strBuffer.WriteString(fmt.Sprintf("Avatar: %s\n", data.authorAvatarUrl)) 363 | strBuffer.WriteString(fmt.Sprintf("Company: %s\n", data.authorCompany)) 364 | 365 | return strBuffer.String() 366 | } 367 | 368 | // Format a string slice to be stored in a csv 369 | func stringifySlice(s []string) string { 370 | var buffer string 371 | for i, item := range s { 372 | if i == 0 { 373 | buffer = item 374 | } else { 375 | buffer = buffer + " " + item 376 | } 377 | } 378 | return buffer 379 | } 380 | 381 | // get the current time as a time stamp unless one is supplied (for testing and header generation purposes) 382 | func getCurrentTimeAsTimeStamp(suppliedTime string) string { 383 | var outBuffer string 384 | if suppliedTime == "" { 385 | t := time.Now().UTC() 386 | outBuffer = fmt.Sprintf("%sZ", t.Format("2006-01-02T15-04-05")) 387 | } else { 388 | outBuffer = suppliedTime 389 | } 390 | 391 | return outBuffer 392 | 393 | } 394 | 395 | // Generates the data part of the CSV (without time stamp). 396 | // Makes it easier to test and to use to generate header 397 | func generateHonoredContributorDataAsCSV(contributorData HonoredContributorData) string { 398 | 399 | outBuffer := fmt.Sprintf("\"%s\", \"%s\", \"%s\", \"%s\", \"%s\", \"%s\", \"%s\", \"%s\"", 400 | contributorData.month, 401 | contributorData.handle, 402 | contributorData.fullName, 403 | contributorData.authorCompany, 404 | contributorData.authorURL, 405 | contributorData.authorAvatarUrl, 406 | contributorData.totalPRs_found, 407 | contributorData.repositories, 408 | ) 409 | 410 | return outBuffer 411 | } 412 | 413 | // Generates the data part of the CSV (without time stamp). 414 | // Makes it easier to test and to use to generate header 415 | func generateHonoredContributorDataCSVheader() string { 416 | var headerData HonoredContributorData = HonoredContributorData{ 417 | handle: "GH_HANDLE", 418 | fullName: "FULL_NAME", 419 | authorURL: "GH_HANDLE_URL", 420 | authorAvatarUrl: "GH_HANDLE_AVATAR", 421 | authorCompany: "COMPANY", 422 | month: "MONTH", 423 | totalPRs_found: "NBR_PR", 424 | totalPRs_expected: "", 425 | repositories: "REPOSITORIES", 426 | } 427 | 428 | shortHeader := generateHonoredContributorDataAsCSV(headerData) 429 | outBuffer := "\"RUN_DATE\", " + shortHeader 430 | 431 | return outBuffer 432 | } 433 | -------------------------------------------------------------------------------- /cmd/remove_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 | "fmt" 27 | "io" 28 | "os" 29 | "path/filepath" 30 | "reflect" 31 | "regexp" 32 | "strings" 33 | "testing" 34 | 35 | "github.com/stretchr/testify/assert" 36 | ) 37 | 38 | func Test_ComputeRemoveBackupFile(t *testing.T) { 39 | result_regexp, _ := regexp.Compile(`^(.|test_data)/removeBackup_20[0-3][0-9][0-1][0-9][0-3][0-9]_[0-2][0-9][0-5][0-9][0-5][0-9]__testFile\.csv$`) 40 | 41 | backupFileName := compute_removeBackupFileName("testFile.csv") 42 | assert.True(t, result_regexp.MatchString(backupFileName), "Backup file name (%s) doesn't have the expected format", backupFileName) 43 | 44 | backupFileName = compute_removeBackupFileName("test_data/testFile.csv") 45 | assert.True(t, result_regexp.MatchString(backupFileName), "Backup file name (%s) doesn't have the expected format", backupFileName) 46 | } 47 | 48 | func Test_ExecuteMustHaveTwoArguments(t *testing.T) { 49 | actual := new(bytes.Buffer) 50 | rootCmd.SetOut(actual) 51 | rootCmd.SetErr(actual) 52 | rootCmd.SetArgs([]string{"remove", "A"}) 53 | error := rootCmd.Execute() 54 | 55 | assert.Error(t, error, "Function call should have failed") 56 | 57 | //Error is expected 58 | expectedMsg := "Error: requires at least 2 arg(s), only received 1" 59 | lines := strings.Split(actual.String(), "\n") 60 | assert.Equal(t, expectedMsg, lines[0], "Function did not fail for the expected cause") 61 | } 62 | 63 | // This is an end to end test 64 | func Test_ExecuteIntegrationTest(t *testing.T) { 65 | 66 | // Setup environment 67 | tempDir := t.TempDir() 68 | tempFileName, err := duplicateFile("../test-data/submissions-2023-08.csv", tempDir, true) 69 | 70 | assert.NoError(t, err, "Unexpected File duplication error") 71 | assert.NotEmpty(t, tempFileName, "Unexpected empty temporary filename") 72 | 73 | actual := new(bytes.Buffer) 74 | rootCmd.SetOut(actual) 75 | rootCmd.SetErr(actual) 76 | var commandArguments []string 77 | commandArguments = append(commandArguments, "remove", "File:../test-data/test-exclusion.txt", tempFileName, "-b") 78 | rootCmd.SetArgs(commandArguments) 79 | error := rootCmd.Execute() 80 | 81 | assert.NoError(t, error, "Function should not have failed") 82 | 83 | // Compare output with reference (golden) file 84 | goldenFileName := "../test-data/submissions-2023-08_cleaned.csv" 85 | assert.True(t, isFileEquivalent(tempFileName, goldenFileName)) 86 | 87 | //Does the backup file exist? 88 | backupFileName := compute_removeBackupFileName(tempFileName) 89 | assert.FileExistsf(t, backupFileName, "Backup file (%s) has not been created.", backupFileName) 90 | } 91 | 92 | func Test_performRemove(t *testing.T) { 93 | type args struct { 94 | githubUser string 95 | fileToClean_name string 96 | isBackup bool 97 | } 98 | tests := []struct { 99 | name string 100 | args args 101 | wantErr bool 102 | }{ 103 | { 104 | "Invalid GitHub user", 105 | args{ 106 | githubUser: "ax_4!", 107 | fileToClean_name: "", 108 | isBackup: true, 109 | }, 110 | true, 111 | }, 112 | { 113 | "Non existent file", 114 | args{ 115 | githubUser: "jenkinsci", 116 | fileToClean_name: "unexistantFile.txt", 117 | isBackup: true, 118 | }, 119 | true, 120 | }, 121 | } 122 | for _, tt := range tests { 123 | t.Run(tt.name, func(t *testing.T) { 124 | if err := performRemove(tt.args.githubUser, tt.args.fileToClean_name, tt.args.isBackup); (err != nil) != tt.wantErr { 125 | t.Errorf("performRemove() error = %v, wantErr %v", err, tt.wantErr) 126 | } 127 | }) 128 | } 129 | } 130 | 131 | var expectedSubmittersList = []string{ 132 | "org,repository,number,url,state,created_at,merged_at,user.login,month_year,title", 133 | "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 134 | "\"jenkinsci\",\"ldap-plugin\",248,\"https://github.com/jenkinsci/ldap-plugin/pull/248\",\"closed\",\"2023-08-12T12:09:11Z\",\"2023-09-22T16:21:31Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 135 | "\"jenkinsci\",\"ecu-test-execution-plugin\",54,\"https://github.com/jenkinsci/ecu-test-execution-plugin/pull/54\",\"closed\",\"2023-08-07T10:06:24Z\",\"2023-09-22T09:03:34Z\",\"MxEh-TT\",\"2023-08\",\"inital package check implementation (#53)\"", 136 | "\"jenkinsci\",\"build-blocker-plugin\",19,\"https://github.com/jenkinsci/build-blocker-plugin/pull/19\",\"closed\",\"2023-08-07T06:35:02Z\",\"2023-09-18T13:42:06Z\",\"olamy\",\"2023-08\",\"add @Symbol to be able to easily use the plugin in a declarative pipeline\"", 137 | "\"jenkinsci\",\"credentials-plugin\",475,\"https://github.com/jenkinsci/credentials-plugin/pull/475\",\"closed\",\"2023-08-12T08:16:01Z\",\"2023-09-21T16:16:52Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 138 | "\"jenkinsci\",\"ssh-credentials-plugin\",179,\"https://github.com/jenkinsci/ssh-credentials-plugin/pull/179\",\"closed\",\"2023-08-12T08:32:14Z\",\"2023-09-21T16:12:07Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 139 | } 140 | 141 | // Removed "olamy" 142 | var cleanedSubmittersList = []string{ 143 | "org,repository,number,url,state,created_at,merged_at,user.login,month_year,title", 144 | "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 145 | "\"jenkinsci\",\"ldap-plugin\",248,\"https://github.com/jenkinsci/ldap-plugin/pull/248\",\"closed\",\"2023-08-12T12:09:11Z\",\"2023-09-22T16:21:31Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 146 | "\"jenkinsci\",\"ecu-test-execution-plugin\",54,\"https://github.com/jenkinsci/ecu-test-execution-plugin/pull/54\",\"closed\",\"2023-08-07T10:06:24Z\",\"2023-09-22T09:03:34Z\",\"MxEh-TT\",\"2023-08\",\"inital package check implementation (#53)\"", 147 | "\"jenkinsci\",\"credentials-plugin\",475,\"https://github.com/jenkinsci/credentials-plugin/pull/475\",\"closed\",\"2023-08-12T08:16:01Z\",\"2023-09-21T16:16:52Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 148 | "\"jenkinsci\",\"ssh-credentials-plugin\",179,\"https://github.com/jenkinsci/ssh-credentials-plugin/pull/179\",\"closed\",\"2023-08-12T08:32:14Z\",\"2023-09-21T16:12:07Z\",\"NotMyFault\",\"2023-08\",\"Test on Java 21\"", 149 | } 150 | 151 | func Test_loadCSVtoClean(t *testing.T) { 152 | type args struct { 153 | fileName string 154 | } 155 | tests := []struct { 156 | name string 157 | args args 158 | want []string 159 | wantErr bool 160 | }{ 161 | { 162 | "Happy Case", 163 | args{fileName: "../test-data/small-submission-list.csv"}, 164 | expectedSubmittersList, 165 | false, 166 | }, 167 | { 168 | "Empty File", 169 | args{fileName: "../test-data/empty-submission-list.csv"}, 170 | nil, 171 | true, 172 | }, 173 | { 174 | "Inexistent File", 175 | args{fileName: "blaahhh.csv"}, 176 | nil, 177 | true, 178 | }, 179 | } 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | err, got := loadCSVtoClean(tt.args.fileName) 183 | if (err != nil) != tt.wantErr { 184 | t.Errorf("loadCSVtoClean() error = %v, wantErr %v", err, tt.wantErr) 185 | return 186 | } 187 | if !reflect.DeepEqual(got, tt.want) { 188 | t.Errorf("loadCSVtoClean() = %v, want %v", got, tt.want) 189 | } 190 | }) 191 | } 192 | } 193 | 194 | func Test_cleanCsvList(t *testing.T) { 195 | type args struct { 196 | csvToCleanList []string 197 | githubUserList []string 198 | } 199 | tests := []struct { 200 | name string 201 | args args 202 | want []string 203 | }{ 204 | { 205 | "happy case", 206 | args{ 207 | csvToCleanList: expectedSubmittersList, 208 | githubUserList: []string{"olamy"}, 209 | }, 210 | cleanedSubmittersList, 211 | }, 212 | } 213 | for _, tt := range tests { 214 | t.Run(tt.name, func(t *testing.T) { 215 | if got := cleanCsvList(tt.args.csvToCleanList, tt.args.githubUserList); !reflect.DeepEqual(got, tt.want) { 216 | t.Errorf("cleanCsvList() = %v, want %v", got, tt.want) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | func Test_listItemContainedInLine(t *testing.T) { 223 | type args struct { 224 | line string 225 | userList []string 226 | } 227 | tests := []struct { 228 | name string 229 | args args 230 | want bool 231 | }{ 232 | { 233 | "detected user from single user list", 234 | args{ 235 | line: "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 236 | userList: []string{"MarkEWaite"}, 237 | }, 238 | true, 239 | }, 240 | { 241 | "detected user from multi user list", 242 | args{ 243 | line: "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 244 | userList: []string{"user1", "MarkEWaite"}, 245 | }, 246 | true, 247 | }, 248 | { 249 | "undetected user from multi user list", 250 | args{ 251 | line: "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 252 | userList: []string{"user1", "oLamy"}, 253 | }, 254 | false, 255 | }, 256 | { 257 | "empty user list", 258 | args{ 259 | line: "\"jenkinsci\",\"embeddable-build-status-plugin\",229,\"https://github.com/jenkinsci/embeddable-build-status-plugin/pull/229\",\"closed\",\"2023-08-11T21:18:19Z\",\"2023-08-12T03:55:01Z\",\"MarkEWaite\",\"2023-08\",\"Test with Java 21\"", 260 | userList: []string{}, 261 | }, 262 | false, 263 | }, 264 | } 265 | for _, tt := range tests { 266 | t.Run(tt.name, func(t *testing.T) { 267 | if got := listItemContainedInLine(tt.args.line, tt.args.userList); got != tt.want { 268 | t.Errorf("listItemContainedInLine() = %v, want %v", got, tt.want) 269 | } 270 | }) 271 | } 272 | } 273 | 274 | func Test_isFileSpec(t *testing.T) { 275 | type args struct { 276 | input string 277 | } 278 | tests := []struct { 279 | name string 280 | args args 281 | want string 282 | }{ 283 | { 284 | "not a file", 285 | args{input: "gitHubUser"}, 286 | "", 287 | }, 288 | { 289 | "with filespec", 290 | args{input: "file:thisIsAFile.txt"}, 291 | "thisIsAFile.txt", 292 | }, 293 | { 294 | "with mixed case filespec", 295 | args{input: "File:thisIsAFile.txt"}, 296 | "thisIsAFile.txt", 297 | }, 298 | } 299 | for _, tt := range tests { 300 | t.Run(tt.name, func(t *testing.T) { 301 | if got := isFileSpec(tt.args.input); got != tt.want { 302 | t.Errorf("isFileSpec() = %v, want %v", got, tt.want) 303 | } 304 | }) 305 | } 306 | } 307 | 308 | // ------------------------------ 309 | // 310 | // Test Utilities 311 | // 312 | // ------------------------------ 313 | 314 | // duplicate test file as a temporary file. 315 | // The temporary directory should be created in the calling test so that it gets cleaned at test completion. 316 | func duplicateFile(originalFileName, targetDir string, generateFilename bool) (tempFileName string, err error) { 317 | 318 | //Check the status and size of the original file 319 | sourceFileStat, err := os.Stat(originalFileName) 320 | if err != nil { 321 | return "", err 322 | } 323 | if !sourceFileStat.Mode().IsRegular() { 324 | return "", fmt.Errorf("%s is not a regular file", originalFileName) 325 | } 326 | sourceFileSize := sourceFileStat.Size() 327 | 328 | //Open the original file 329 | source, err := os.Open(originalFileName) 330 | if err != nil { 331 | return "", err 332 | } 333 | defer source.Close() 334 | 335 | // generate temporary file name in temp directory if requested 336 | if generateFilename { 337 | file, err := os.CreateTemp(targetDir, "testData.*.csv") 338 | if err != nil { 339 | return "", err 340 | } 341 | tempFileName = file.Name() 342 | } else { 343 | //we want to keep the original filename 344 | _, file := filepath.Split(originalFileName) 345 | tempFileName = filepath.Join(targetDir, file) 346 | } 347 | 348 | // create the new file duplication 349 | destination, err := os.Create(tempFileName) 350 | if err != nil { 351 | return "", err 352 | } 353 | defer destination.Close() 354 | 355 | // Do the actual copy 356 | bytesCopied, err := io.Copy(destination, source) 357 | if err != nil { 358 | return tempFileName, err 359 | } 360 | if bytesCopied != sourceFileSize { 361 | 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) 362 | } 363 | 364 | // All went well 365 | return tempFileName, nil 366 | } 367 | 368 | func isFileEquivalent(tempFileName, goldenFileName string) bool { 369 | 370 | // Is the size the same 371 | tempFileSize := getFileSize(tempFileName) 372 | goldenFileSize := getFileSize(goldenFileName) 373 | 374 | if tempFileSize == 0 || goldenFileSize == 0 { 375 | fmt.Printf("0 byte file length\n") 376 | return false 377 | } 378 | 379 | if tempFileSize != goldenFileSize { 380 | fmt.Printf("Files are of different sizes: found %d bytes while expecting reference %d bytes \n", tempFileSize, goldenFileSize) 381 | return false 382 | } 383 | 384 | // load both files 385 | err, tempFile_List := loadCSVtoClean(tempFileName) 386 | if err != nil { 387 | fmt.Printf("Unexpected error loading %s : %v \n", tempFileName, err) 388 | return false 389 | } 390 | 391 | err, goldenFile_List := loadCSVtoClean(goldenFileName) 392 | if err != nil { 393 | fmt.Printf("Unexpected error loading %s : %v \n", goldenFileName, err) 394 | return false 395 | } 396 | 397 | //Compare the two lists 398 | for index, line := range tempFile_List { 399 | if line != goldenFile_List[index] { 400 | fmt.Printf("Compare failure: line %d do not match\n", index) 401 | return false 402 | } 403 | } 404 | 405 | //If we reached this, we are all good 406 | return true 407 | } 408 | 409 | // Gets the size of a file 410 | func getFileSize(fileName string) int64 { 411 | tempFileStat, err := os.Stat(fileName) 412 | if err != nil { 413 | fmt.Printf("Unexpected error getting details of %s: %v\n", fileName, err) 414 | return 0 415 | } 416 | if !tempFileStat.Mode().IsRegular() { 417 | fmt.Printf("%s is not a regular file\n", fileName) 418 | return 0 419 | } 420 | return tempFileStat.Size() 421 | } 422 | -------------------------------------------------------------------------------- /cmd/get-submitters.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 | "context" 26 | "fmt" 27 | "os" 28 | "regexp" 29 | "time" 30 | 31 | "github.com/schollz/progressbar/v3" 32 | "github.com/shurcooL/githubv4" 33 | "github.com/spf13/cobra" 34 | "golang.org/x/oauth2" 35 | ) 36 | 37 | var isSkipClosed bool 38 | 39 | // prCmd represents the pr command 40 | var prCmd = &cobra.Command{ 41 | Use: "submitters [org] [YYYY-MM]", 42 | Short: "Get all PRs (and their submitters) for a given month and org.", 43 | Long: `Get all PRs (and their submitters) for a given month and org.`, 44 | Args: func(cmd *cobra.Command, args []string) error { 45 | //call requires two parameters (org and month) 46 | if err := cobra.MinimumNArgs(2)(cmd, args); err != nil { 47 | return err 48 | } 49 | if !isValidOrgFormat(args[0]) { 50 | return fmt.Errorf("ERROR: %s is not a valid GitHub user or Org name.\n", args[0]) 51 | } 52 | 53 | if !isValidMonthFormat(args[1]) { 54 | return fmt.Errorf("ERROR: %s is not a valid month (should be \"YYYY-MM\").\n", args[1]) 55 | } 56 | 57 | // We probably have a file with users to exclude 58 | if excludeFileName != "" { 59 | var err error 60 | err, excludedGithubUsers = load_exclusions(excludeFileName) 61 | if err != nil { 62 | return fmt.Errorf("invalid excluded user list => %v\n", err) 63 | } 64 | } 65 | 66 | return nil 67 | }, 68 | RunE: func(cmd *cobra.Command, args []string) error { 69 | err := performSearch(args[0], args[1]) 70 | if err != nil { 71 | return err 72 | } 73 | return nil 74 | }, 75 | } 76 | 77 | func init() { 78 | prCmd.PersistentFlags().BoolVarP(&isSkipClosed, "skip_closed", "", false, "Skip PR marked as closed.") 79 | getCmd.AddCommand(prCmd) 80 | 81 | //TODO: separate output default: https://github.com/spf13/cobra/issues/553 and https://travis.media/how-to-use-subcommands-in-cobra-go-cobra-tutorial/ 82 | 83 | } 84 | 85 | // ************************* 86 | // ************************* 87 | 88 | // Main function: it searches GitHub for all PRs created in the given month and writes it to a CSV 89 | func performSearch(searchedOrg string, searchedMonth string) error { 90 | initLoggers() 91 | if isRootDebug { 92 | loggers.debug.Println("******** New \"Get Submitters\" debug session ********") 93 | } 94 | 95 | if isRootDebug { 96 | fmt.Print("*** Debug mode enabled ***\nSee \"debug.log\" for the trace\n\n") 97 | 98 | limit, remaining, _, _ := get_quota_data_v4() 99 | loggers.debug.Printf("Start quota: %d/%d\n", remaining, limit) 100 | } 101 | 102 | // Check whether we will not get too many items, forcing us to split 103 | nbrOfItems, errGetTotal := getTotalNumberOfItems(searchedOrg, searchedMonth) 104 | if errGetTotal != nil { 105 | return errGetTotal 106 | } 107 | if isRootDebug { 108 | loggers.debug.Printf("Total number of items in month: %d\n", nbrOfItems) 109 | } 110 | 111 | var output_data_list []string 112 | 113 | if nbrOfItems > 1000 { 114 | hasMore := true 115 | i := 0 116 | startDate := "" 117 | endDate := "" 118 | loadedItems := 0 119 | for hasMore { 120 | startDate, endDate, hasMore = splitPeriodForMaxQueryItem(nbrOfItems, searchedMonth, i) 121 | output_list, itemsInIterations, err := getData(searchedOrg, startDate, endDate) 122 | if err != nil { 123 | return err 124 | } 125 | output_data_list = append(output_data_list, output_list...) 126 | loadedItems = loadedItems + itemsInIterations 127 | i++ 128 | } 129 | if isRootDebug { 130 | loggers.debug.Printf("expected nbr of items (%d) vs. retrieved nbr of items (%d)\n", nbrOfItems, loadedItems) 131 | } 132 | if nbrOfItems != loadedItems { 133 | return fmt.Errorf("Expected nbr of items (%d) does not match retrieved nbr of items (%d)", nbrOfItems, loadedItems) 134 | } 135 | 136 | } else { 137 | startDate, endDate := getStartAndEndOfMonth(searchedMonth) 138 | // A value of 0001-01-01 and 0001-01-31 indicates a rubbish input. Input is validated higher, so we don't check this here 139 | 140 | //get the data from GitHub 141 | var err error 142 | output_data_list, _, err = getData(searchedOrg, startDate, endDate) 143 | if err != nil { 144 | return err 145 | } 146 | } 147 | 148 | // Write to CSV 149 | isAppend := globalIsAppend 150 | if !globalIsAppend { 151 | // Meaning that we need to create a new file 152 | if fileExist(outputFileName) { 153 | os.Remove(outputFileName) 154 | } 155 | isAppend = true 156 | } 157 | 158 | // We make no difference whether data was found or not 159 | 160 | // Creates, overwrites, or opens for append depending on the combination 161 | out, newIsNoHeader := openOutputCSV(outputFileName, isAppend, globalIsNoHeader) 162 | defer out.Close() 163 | 164 | //TODO: Refactor 165 | header := "org,repository,number,url,state,created_at,merged_at,user.login,month_year,title" 166 | writeCSVtoFile(out, isAppend, newIsNoHeader, header, output_data_list) 167 | out.Close() 168 | 169 | return nil 170 | } 171 | 172 | // Gets the data from GitHub for all PRs created in the given month 173 | func getData(searchedOrg string, startDate string, endDate string) ([]string, int, error) { 174 | // initLoggers() 175 | 176 | //note: parameters are checked at Cobra API level 177 | 178 | ghToken := loadGitHubToken(ghTokenVar) 179 | src := oauth2.StaticTokenSource( 180 | &oauth2.Token{AccessToken: ghToken}, 181 | ) 182 | httpClient := oauth2.NewClient(context.Background(), src) 183 | client := githubv4.NewClient(httpClient) 184 | 185 | var prList []string 186 | issueCount := 0 187 | 188 | { 189 | var prQuery struct { 190 | Viewer struct { 191 | Login string 192 | } 193 | RateLimit struct { 194 | Limit int 195 | Cost int 196 | Remaining int 197 | ResetAt time.Time 198 | } 199 | Search struct { 200 | IssueCount int 201 | Edges []struct { 202 | Node struct { 203 | PullRequest struct { 204 | Repository struct { 205 | Name string 206 | Owner struct { 207 | Login string 208 | } 209 | } 210 | Author struct { 211 | Login string 212 | ResourcePath string 213 | } 214 | CreatedAt time.Time 215 | MergedAt time.Time 216 | State string 217 | Url string 218 | Number int 219 | Title string 220 | } `graphql:"... on PullRequest"` 221 | } 222 | } 223 | PageInfo struct { 224 | EndCursor githubv4.String 225 | HasNextPage bool 226 | } 227 | } `graphql:"search(first: $count, after: $pullRequestCursor, query: $searchQuery, type: ISSUE)"` 228 | } 229 | 230 | variables := map[string]interface{}{ 231 | "searchQuery": githubv4.String( 232 | fmt.Sprintf(`org:%s is:pr -author:app/dependabot -author:app/renovate -author:app/github-actions -author:jenkins-infra-bot created:%s..%s`, 233 | githubv4.String(searchedOrg), 234 | githubv4.String(startDate), 235 | githubv4.String(endDate), 236 | ), 237 | ), 238 | "count": githubv4.Int(100), 239 | "pullRequestCursor": (*githubv4.String)(nil), // Null after argument to get first page. 240 | } 241 | 242 | //TODO: solve issue of different default output file for this command 243 | //TODO: handle quota wait 244 | 245 | var bar *progressbar.ProgressBar 246 | barDescription := fmt.Sprintf("%s %s->%s ", searchedOrg, startDate, endDate) 247 | if !isVerbose { 248 | bar = progressbar.NewOptions( 249 | 1000, 250 | progressbar.OptionShowBytes(false), 251 | progressbar.OptionSetDescription(barDescription), 252 | progressbar.OptionSetPredictTime(false), 253 | progressbar.OptionShowBytes(false), 254 | progressbar.OptionFullWidth(), 255 | progressbar.OptionShowCount(), 256 | ) 257 | //TODO: treat error 258 | _ = bar.Add(1) 259 | } 260 | 261 | i := 0 262 | for { 263 | err := client.Query(context.Background(), &prQuery, variables) 264 | if err != nil { 265 | if isRootDebug { 266 | loggers.debug.Printf("Error performing query: %v\n", err) 267 | } 268 | var emptyList []string 269 | return emptyList, 0, err 270 | } 271 | 272 | if isRootDebug { 273 | loggers.debug.Printf("GitHub query successful: retrieved %d PRs", len(prQuery.Search.Edges)) 274 | } 275 | 276 | // We update the progress bar with the total size we get with the first call 277 | totalIssues := prQuery.Search.IssueCount 278 | if i == 0 && !isVerbose { 279 | if isRootDebug { 280 | loggers.debug.Printf("Expecting to treat %d items. Resetting progress bar\n", totalIssues) 281 | } 282 | issueCount = totalIssues 283 | // +1 to compensate the initial add() we used to display the bar 284 | bar.ChangeMax(totalIssues + 1) 285 | } 286 | 287 | for ii, singlePr := range prQuery.Search.Edges { 288 | 289 | if !isVerbose { 290 | //TODO: treat error 291 | _ = bar.Add(1) 292 | } 293 | 294 | createdAtStr := "" 295 | if !singlePr.Node.PullRequest.CreatedAt.IsZero() { 296 | createdAtStr = singlePr.Node.PullRequest.CreatedAt.Format(time.RFC3339) //created At 297 | } 298 | 299 | mergedAtStr := "" 300 | if !singlePr.Node.PullRequest.MergedAt.IsZero() { 301 | mergedAtStr = singlePr.Node.PullRequest.MergedAt.Format(time.RFC3339) //mergedAt, if available 302 | } 303 | 304 | author := "" 305 | // Applications have a RessourcePath that starts with "/apps" and we don't count them 306 | regexpApp := regexp.MustCompile(`^\/apps\/`) 307 | if regexpApp.MatchString(singlePr.Node.PullRequest.Author.ResourcePath) { 308 | if isRootDebug { 309 | loggers.debug.Printf(" %d-%d (%d/%d) Skipping %s because user %s is an application.\n", 310 | i, ii, (i*100)+ii, totalIssues, 311 | singlePr.Node.PullRequest.Url, 312 | singlePr.Node.PullRequest.Author.ResourcePath) 313 | } 314 | continue 315 | } else { 316 | // Is it an author that we don't want to track ? 317 | authorToCheck := singlePr.Node.PullRequest.Author.Login 318 | if !isExcludedAuthor(excludedGithubUsers, authorToCheck) { 319 | author = authorToCheck 320 | } else { 321 | continue 322 | } 323 | 324 | } 325 | 326 | // Skip PR if the status is CLOSED (Same behavior as the bash extraction) 327 | if isSkipClosed { 328 | if singlePr.Node.PullRequest.State == "CLOSED" { 329 | if isRootDebug { 330 | loggers.debug.Printf(" %d-%d (%d/%d) Skipping %s because it is CLOSED\n", 331 | i, ii, (i*100)+ii, totalIssues, singlePr.Node.PullRequest.Url) 332 | } 333 | continue 334 | } 335 | } 336 | 337 | // clean and shorten the title 338 | cleanedTitle := truncateString(cleanBody(singlePr.Node.PullRequest.Title), 30) 339 | 340 | // data format: "org,repository,number,url,state,created_at,merged_at,user.login,month_year,title" 341 | 342 | dataLine := fmt.Sprintf("\"%s\",\"%s\",%d,\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"", 343 | singlePr.Node.PullRequest.Repository.Owner.Login, // Org 344 | singlePr.Node.PullRequest.Repository.Name, //repository 345 | singlePr.Node.PullRequest.Number, // PR number 346 | singlePr.Node.PullRequest.Url, // PR's URL 347 | singlePr.Node.PullRequest.State, // PR's state 348 | createdAtStr, // Creation date&time 349 | mergedAtStr, // Merged date&time 350 | author, // PR's author 351 | singlePr.Node.PullRequest.CreatedAt.Format("2006-01"), // Creation month-year 352 | cleanedTitle, // PR's description 353 | ) 354 | 355 | if isRootDebug { 356 | loggers.debug.Printf(" %d-%d (%d/%d) %s\n", i, ii, (i*100)+ii, totalIssues, dataLine) 357 | } 358 | prList = append(prList, dataLine) 359 | 360 | if isVerbose { 361 | fmt.Printf("%d-%d (%d/%d) %s %s\n", i, ii, (i*100)+ii, totalIssues, singlePr.Node.PullRequest.Author.Login, singlePr.Node.PullRequest.Url) 362 | } 363 | } 364 | 365 | if !prQuery.Search.PageInfo.HasNextPage { 366 | if isRootDebug { 367 | loggers.debug.Printf("HasNextPage is set to false. Exiting loop...\n") 368 | } 369 | break 370 | } 371 | variables["pullRequestCursor"] = githubv4.NewString(prQuery.Search.PageInfo.EndCursor) 372 | i++ 373 | 374 | // Function has its own debug trace 375 | checkIfSufficientQuota_2(2, 376 | prQuery.RateLimit.Remaining, 377 | prQuery.RateLimit.Limit, 378 | prQuery.RateLimit.ResetAt) 379 | } 380 | } 381 | // as the progress exist doesn't do it 382 | fmt.Printf("\n") 383 | return prList, issueCount, nil 384 | } 385 | 386 | // Makes a call to GitHub to get the total number of items. We can handle only 1K items in one 387 | // series of call. If above 1K we will have to split by decreasing the date range. 388 | func getTotalNumberOfItems(searchedOrg string, searchedMonth string) (int, error) { 389 | ghToken := loadGitHubToken(ghTokenVar) 390 | src := oauth2.StaticTokenSource( 391 | &oauth2.Token{AccessToken: ghToken}, 392 | ) 393 | httpClient := oauth2.NewClient(context.Background(), src) 394 | client := githubv4.NewClient(httpClient) 395 | 396 | var prQuery struct { 397 | Viewer struct { 398 | Login string 399 | } 400 | RateLimit struct { 401 | Limit int 402 | Cost int 403 | Remaining int 404 | ResetAt time.Time 405 | } 406 | Search struct { 407 | IssueCount int 408 | Edges []struct { 409 | Node struct { 410 | PullRequest struct { 411 | Url string 412 | Number int 413 | Title string 414 | } `graphql:"... on PullRequest"` 415 | } 416 | } 417 | PageInfo struct { 418 | EndCursor githubv4.String 419 | HasNextPage bool 420 | } 421 | } `graphql:"search(first: $count, after: $pullRequestCursor, query: $searchQuery, type: ISSUE)"` 422 | } 423 | 424 | startDate, endDate := getStartAndEndOfMonth(searchedMonth) 425 | // A value of 0001-01-01 and 0001-01-31 indicates a rubbish input. Input is validated higher, so we don't check this here 426 | 427 | variables := map[string]interface{}{ 428 | "searchQuery": githubv4.String( 429 | fmt.Sprintf(`org:%s is:pr -author:app/dependabot -author:app/renovate -author:app/github-actions -author:jenkins-infra-bot created:%s..%s`, 430 | githubv4.String(searchedOrg), 431 | githubv4.String(startDate), 432 | githubv4.String(endDate), 433 | ), 434 | ), 435 | "count": githubv4.Int(100), 436 | "pullRequestCursor": (*githubv4.String)(nil), // Null after argument to get first page. 437 | } 438 | 439 | // Make the call 440 | err := client.Query(context.Background(), &prQuery, variables) 441 | if err != nil { 442 | if isRootDebug { 443 | loggers.debug.Printf("Error performing query: %v\n", err) 444 | } 445 | return 0, err 446 | } 447 | 448 | if isRootDebug { 449 | loggers.debug.Printf("GitHub query successful: retrieved %d PRs", len(prQuery.Search.Edges)) 450 | } 451 | 452 | // We update the progress bar with the total size we get with the first call 453 | totalIssues := prQuery.Search.IssueCount 454 | 455 | return totalIssues, nil 456 | } 457 | 458 | //GitHub Graphql query. Test at https://docs.github.com/en/graphql/overview/explorer 459 | /* 460 | { 461 | rateLimit { 462 | limit 463 | cost 464 | remaining 465 | resetAt 466 | } 467 | search( 468 | query: "org:jenkinsci is:pr -author:app/dependabot -author:app/renovate -author:jenkins-infra-bot created:2023-09-01..2023-09-30" 469 | type: ISSUE 470 | first: 100 471 | ) { 472 | issueCount 473 | pageInfo { 474 | endCursor 475 | hasNextPage 476 | } 477 | edges { 478 | node { 479 | ... on PullRequest { 480 | repository { 481 | name 482 | owner { 483 | login 484 | } 485 | } 486 | number 487 | url 488 | state 489 | createdAt 490 | closedAt 491 | author { 492 | login 493 | resourcePath 494 | } 495 | title 496 | } 497 | } 498 | } 499 | } 500 | } 501 | */ 502 | -------------------------------------------------------------------------------- /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 | "testing" 26 | ) 27 | 28 | func Test_validatePRspec(t *testing.T) { 29 | type args struct { 30 | prSpec string 31 | } 32 | tests := []struct { 33 | name string 34 | args args 35 | wantOrg string 36 | wantProject string 37 | wantPrNbr int 38 | wantErr bool 39 | }{ 40 | { 41 | "happy case", 42 | args{prSpec: "on4kjm/FLEcli/1"}, 43 | "on4kjm", "FLEcli", 1, false, 44 | }, 45 | { 46 | "non numeric PR", 47 | args{prSpec: "on4kjm/FLEcli/aa"}, 48 | "", "", -1, true, 49 | }, 50 | { 51 | "empty first field", 52 | args{prSpec: "/FLEcli/1"}, 53 | "", "", -1, true, 54 | }, 55 | { 56 | "empty second field", 57 | args{prSpec: "on4kjm//1"}, 58 | "", "", -1, true, 59 | }, 60 | { 61 | "empty third field", 62 | args{prSpec: "on4kjm/FLEcli/"}, 63 | "", "", -1, true, 64 | }, 65 | { 66 | "too short", 67 | args{prSpec: "on4kjm/FLEcli"}, 68 | "", "", -1, true, 69 | }, 70 | { 71 | "too long", 72 | args{prSpec: "on4kjm/FLEcli/1/zzz"}, 73 | "", "", -1, true, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | gotOrg, gotProject, gotPrNbr, err := validatePRspec(tt.args.prSpec) 79 | if (err != nil) != tt.wantErr { 80 | t.Errorf("validatePRspec() error = %v, wantErr %v", err, tt.wantErr) 81 | return 82 | } 83 | if gotOrg != tt.wantOrg { 84 | t.Errorf("validatePRspec() gotOrg = %v, want %v", gotOrg, tt.wantOrg) 85 | } 86 | if gotProject != tt.wantProject { 87 | t.Errorf("validatePRspec() gotProject = %v, want %v", gotProject, tt.wantProject) 88 | } 89 | if gotPrNbr != tt.wantPrNbr { 90 | t.Errorf("validatePRspec() gotPrNbr = %v, want %v", gotPrNbr, tt.wantPrNbr) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | func Test_fileExist(t *testing.T) { 97 | type args struct { 98 | fileName string 99 | } 100 | tests := []struct { 101 | name string 102 | args args 103 | want bool 104 | }{ 105 | { 106 | "Happy case", 107 | args{"../test-data/big-submission-list.csv"}, 108 | true, 109 | }, 110 | { 111 | "File does not exist", 112 | args{"unexistantFile.txt"}, 113 | false, 114 | }, 115 | { 116 | "File is a directory in fact", 117 | args{"../test-data"}, 118 | false, 119 | }, 120 | } 121 | 122 | for _, tt := range tests { 123 | t.Run(tt.name, func(t *testing.T) { 124 | if got := fileExist(tt.args.fileName); got != tt.want { 125 | t.Errorf("fileExist() = %v, want %v", got, tt.want) 126 | } 127 | }) 128 | } 129 | } 130 | 131 | func Test_cleanBody(t *testing.T) { 132 | type args struct { 133 | input string 134 | } 135 | tests := []struct { 136 | name string 137 | args args 138 | wantOutput string 139 | }{ 140 | { 141 | "Happy case", 142 | args{input: "aa aaa \nbbb bbb\n"}, 143 | "aa aaa bbb bbb ", 144 | }, 145 | { 146 | "Empty string", 147 | args{input: ""}, 148 | "", 149 | }, 150 | { 151 | "No return", 152 | args{input: "aaaa bbbb ccc"}, 153 | "aaaa bbbb ccc", 154 | }, 155 | { 156 | "Truncate string", 157 | args{input: "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj"}, 158 | "aaaa bbbb cccc dddd eeee ffff gggg hhhh...", 159 | }, 160 | } 161 | for _, tt := range tests { 162 | t.Run(tt.name, func(t *testing.T) { 163 | if gotOutput := cleanBody(tt.args.input); gotOutput != tt.wantOutput { 164 | t.Errorf("cleanBody() = %v, want %v", gotOutput, tt.wantOutput) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | // func Test_isValidMonthFormat(t *testing.T) { 171 | // type args struct { 172 | // input string 173 | // } 174 | // tests := []struct { 175 | // name string 176 | // args args 177 | // want bool 178 | // }{ 179 | // { 180 | // "Happy case", 181 | // args{input: "2023-09"}, 182 | // true, 183 | // }, 184 | // { 185 | // "just junk", 186 | // args{input: "junk"}, 187 | // false, 188 | // }, 189 | // { 190 | // "space", 191 | // args{input: " "}, 192 | // false, 193 | // }, 194 | // { 195 | // "empty", 196 | // args{input: ""}, 197 | // false, 198 | // }, 199 | // { 200 | // "invalid month", 201 | // args{input: "2023-16"}, 202 | // false, 203 | // }, 204 | // { 205 | // "invalid year", 206 | // args{input: "1515-06"}, 207 | // false, 208 | // }, 209 | // { 210 | // "too long", 211 | // args{input: "2023-09-13"}, 212 | // false, 213 | // }, 214 | 215 | // } 216 | // for _, tt := range tests { 217 | // t.Run(tt.name, func(t *testing.T) { 218 | // if got := Test_isValidMonthFormat(tt.args.input); got != tt.want { 219 | // t.Errorf("Test_isValidMonthFormat() = %v, want %v", got, tt.want) 220 | // } 221 | // }) 222 | // } 223 | // } 224 | 225 | func Test_isValidMonthFormat(t *testing.T) { 226 | type args struct { 227 | input string 228 | } 229 | tests := []struct { 230 | name string 231 | args args 232 | want bool 233 | }{ 234 | { 235 | "Happy case", 236 | args{input: "2023-09"}, 237 | true, 238 | }, 239 | { 240 | "just junk", 241 | args{input: "junk"}, 242 | false, 243 | }, 244 | { 245 | "space", 246 | args{input: " "}, 247 | false, 248 | }, 249 | { 250 | "empty", 251 | args{input: ""}, 252 | false, 253 | }, 254 | { 255 | "invalid month", 256 | args{input: "2023-16"}, 257 | false, 258 | }, 259 | { 260 | "invalid year", 261 | args{input: "1515-06"}, 262 | false, 263 | }, 264 | { 265 | "too long", 266 | args{input: "2023-09-13"}, 267 | false, 268 | }, 269 | } 270 | for _, tt := range tests { 271 | t.Run(tt.name, func(t *testing.T) { 272 | if got := isValidMonthFormat(tt.args.input); got != tt.want { 273 | t.Errorf("isValidMonthFormat() = %v, want %v", got, tt.want) 274 | } 275 | }) 276 | } 277 | } 278 | 279 | func Test_isValidOrgFormat(t *testing.T) { 280 | type args struct { 281 | input string 282 | } 283 | tests := []struct { 284 | name string 285 | args args 286 | want bool 287 | }{ 288 | { 289 | "Happy case", 290 | args{input: "jenkinsci"}, 291 | true, 292 | }, 293 | { 294 | "space", 295 | args{input: " "}, 296 | false, 297 | }, 298 | { 299 | "empty", 300 | args{input: ""}, 301 | false, 302 | }, 303 | } 304 | for _, tt := range tests { 305 | t.Run(tt.name, func(t *testing.T) { 306 | if got := isValidOrgFormat(tt.args.input); got != tt.want { 307 | t.Errorf("isValidOrgFormat() = %v, want %v", got, tt.want) 308 | } 309 | }) 310 | } 311 | } 312 | 313 | func Test_getStartAndEndOfMonth(t *testing.T) { 314 | type args struct { 315 | shortMonth string 316 | } 317 | tests := []struct { 318 | name string 319 | args args 320 | wantStartDate string 321 | wantEndDate string 322 | }{ 323 | { 324 | "happy case", 325 | args{shortMonth: "2023-09"}, 326 | "2023-09-01", "2023-09-30", 327 | }, 328 | { 329 | "happy case2", 330 | args{shortMonth: "2023-02"}, 331 | "2023-02-01", "2023-02-28", 332 | }, 333 | { 334 | "Rubbish input", 335 | args{shortMonth: "blaahhh"}, 336 | "0001-01-01", "0001-01-31", 337 | }, 338 | } 339 | for _, tt := range tests { 340 | t.Run(tt.name, func(t *testing.T) { 341 | gotStartDate, gotEndDate := getStartAndEndOfMonth(tt.args.shortMonth) 342 | if gotStartDate != tt.wantStartDate { 343 | t.Errorf("getStartAndEndOfMonth() gotStartDate = %v, want %v", gotStartDate, tt.wantStartDate) 344 | } 345 | if gotEndDate != tt.wantEndDate { 346 | t.Errorf("getStartAndEndOfMonth() gotEndDate = %v, want %v", gotEndDate, tt.wantEndDate) 347 | } 348 | }) 349 | } 350 | } 351 | 352 | func Test_splitPeriodForMaxQueryItem(t *testing.T) { 353 | type args struct { 354 | totalNbrIssue int 355 | shortMonth string 356 | requestedIteration int 357 | } 358 | tests := []struct { 359 | name string 360 | args args 361 | wantStartDate string 362 | wantEndDate string 363 | wantMoreIteration bool 364 | }{ 365 | { 366 | "Below limit", 367 | args{ 368 | totalNbrIssue: 800, 369 | shortMonth: "2023-09", 370 | requestedIteration: 0, 371 | }, 372 | "2023-09-01", "2023-09-30", false, 373 | }, 374 | { 375 | "invalid input - negative iteration", 376 | args{ 377 | totalNbrIssue: 800, 378 | shortMonth: "2023-09", 379 | requestedIteration: -1, 380 | }, 381 | "", "", false, 382 | }, 383 | { 384 | "invalid input - negative total issues", 385 | args{ 386 | totalNbrIssue: -1, 387 | shortMonth: "2023-09", 388 | requestedIteration: 0, 389 | }, 390 | "", "", false, 391 | }, 392 | { 393 | "invalid input - unprocessable number of issues", 394 | args{ 395 | totalNbrIssue: 28001, 396 | shortMonth: "2023-09", 397 | requestedIteration: 0, 398 | }, 399 | "", "", false, 400 | }, 401 | { 402 | "Below limit - invalid iteration number", 403 | args{ 404 | totalNbrIssue: 800, 405 | shortMonth: "2023-09", 406 | requestedIteration: 10, 407 | }, 408 | "", "", false, 409 | }, 410 | { 411 | "Below limit - junk short date", 412 | args{ 413 | totalNbrIssue: 800, 414 | shortMonth: "blaah", 415 | requestedIteration: 0, 416 | }, 417 | "", "", false, 418 | }, 419 | { 420 | "Above limit - junk short date", 421 | args{ 422 | totalNbrIssue: 1400, 423 | shortMonth: "blaah", 424 | requestedIteration: 0, 425 | }, 426 | "", "", false, 427 | }, 428 | { 429 | "Above limit (1400) - first iteration", 430 | args{ 431 | totalNbrIssue: 1400, 432 | shortMonth: "2023-09", 433 | requestedIteration: 0, 434 | }, 435 | "2023-09-01", "2023-09-15", true, 436 | }, 437 | { 438 | "Above limit (1400) - second and last iteration", 439 | args{ 440 | totalNbrIssue: 1400, 441 | shortMonth: "2023-09", 442 | requestedIteration: 1, 443 | }, 444 | "2023-09-16", "2023-09-30", false, 445 | }, 446 | { 447 | "Above limit (1900) - second and last iteration", 448 | args{ 449 | totalNbrIssue: 1900, 450 | shortMonth: "2023-09", 451 | requestedIteration: 1, 452 | }, 453 | "2023-09-16", "2023-09-30", false, 454 | }, 455 | { 456 | "Above limit (1900) - out of bound iteration", 457 | args{ 458 | totalNbrIssue: 1900, 459 | shortMonth: "2023-09", 460 | requestedIteration: 4, 461 | }, 462 | "", "", false, 463 | }, 464 | { 465 | "Above limit (2500) - need 3 iterations - 1", 466 | args{ 467 | totalNbrIssue: 2500, 468 | shortMonth: "2023-09", 469 | requestedIteration: 0, 470 | }, 471 | "2023-09-01", "2023-09-10", true, 472 | }, 473 | { 474 | "Above limit (2500) - need 3 iterations - 2", 475 | args{ 476 | totalNbrIssue: 2500, 477 | shortMonth: "2023-09", 478 | requestedIteration: 1, 479 | }, 480 | "2023-09-11", "2023-09-20", true, 481 | }, 482 | { 483 | "Above limit (2500) - need 3 iterations - 3", 484 | args{ 485 | totalNbrIssue: 2500, 486 | shortMonth: "2023-09", 487 | requestedIteration: 2, 488 | }, 489 | "2023-09-21", "2023-09-30", false, 490 | }, 491 | { 492 | "Above limit (1400) 31d - second and last iteration", 493 | args{ 494 | totalNbrIssue: 1400, 495 | shortMonth: "2023-08", 496 | requestedIteration: 1, 497 | }, 498 | "2023-08-16", "2023-08-31", false, 499 | }, 500 | { 501 | "Above limit (2500) 31d - need 3 iterations - 1", 502 | args{ 503 | totalNbrIssue: 2500, 504 | shortMonth: "2023-08", 505 | requestedIteration: 0, 506 | }, 507 | "2023-08-01", "2023-08-10", true, 508 | }, 509 | { 510 | "Above limit (2500) 31d - need 3 iterations - 2", 511 | args{ 512 | totalNbrIssue: 2500, 513 | shortMonth: "2023-08", 514 | requestedIteration: 1, 515 | }, 516 | "2023-08-11", "2023-08-20", true, 517 | }, 518 | { 519 | "Above limit (2500) 31d- need 3 iterations - 3", 520 | args{ 521 | totalNbrIssue: 2500, 522 | shortMonth: "2023-08", 523 | requestedIteration: 2, 524 | }, 525 | "2023-08-21", "2023-08-31", false, 526 | }, 527 | } 528 | for _, tt := range tests { 529 | t.Run(tt.name, func(t *testing.T) { 530 | gotStartDate, gotEndDate, gotMoreIteration := splitPeriodForMaxQueryItem(tt.args.totalNbrIssue, tt.args.shortMonth, tt.args.requestedIteration) 531 | if gotStartDate != tt.wantStartDate { 532 | t.Errorf("splitPeriodForMaxQueryItem() gotStartDate = %v, want %v", gotStartDate, tt.wantStartDate) 533 | } 534 | if gotEndDate != tt.wantEndDate { 535 | t.Errorf("splitPeriodForMaxQueryItem() gotEndDate = %v, want %v", gotEndDate, tt.wantEndDate) 536 | } 537 | if gotMoreIteration != tt.wantMoreIteration { 538 | t.Errorf("splitPeriodForMaxQueryItem() gotMoreIteration = %v, want %v", gotMoreIteration, tt.wantMoreIteration) 539 | } 540 | }) 541 | } 542 | } 543 | 544 | func Test_isUserBot(t *testing.T) { 545 | type args struct { 546 | url string 547 | } 548 | tests := []struct { 549 | name string 550 | args args 551 | want bool 552 | }{ 553 | { 554 | "application", 555 | args{ 556 | url: "https://github.com/apps/codeclimate", 557 | }, 558 | true, 559 | }, 560 | { 561 | "application, upper case", 562 | args{ 563 | url: "HTTPS://GITHUB.com/APPS/CODECLIMAT", 564 | }, 565 | true, 566 | }, 567 | { 568 | "User", 569 | args{ 570 | url: "https://github.com/tofanadrian3000", 571 | }, 572 | false, 573 | }, 574 | { 575 | "Empty URL", 576 | args{ 577 | url: "", 578 | }, 579 | false, 580 | }, 581 | } 582 | for _, tt := range tests { 583 | t.Run(tt.name, func(t *testing.T) { 584 | if got := isUserBot(tt.args.url); got != tt.want { 585 | t.Errorf("isUserBot() = %v, want %v", got, tt.want) 586 | } 587 | }) 588 | } 589 | } 590 | 591 | func Test_isExcludedAuthor(t *testing.T) { 592 | type args struct { 593 | authorList []string 594 | authorToCheck string 595 | } 596 | tests := []struct { 597 | name string 598 | args args 599 | want bool 600 | }{ 601 | { 602 | "in list", 603 | args{ 604 | authorList: []string{"test1", "Test2"}, 605 | authorToCheck: "test2", 606 | }, 607 | true, 608 | }, 609 | { 610 | "in list - single item list", 611 | args{ 612 | authorList: []string{"Test2"}, 613 | authorToCheck: "test2", 614 | }, 615 | true, 616 | }, 617 | { 618 | "not in list", 619 | args{ 620 | authorList: []string{"test1", "Test2"}, 621 | authorToCheck: "aaaa", 622 | }, 623 | false, 624 | }, 625 | { 626 | "not in list - single item list", 627 | args{ 628 | authorList: []string{"Test2"}, 629 | authorToCheck: "aaaa", 630 | }, 631 | false, 632 | }, 633 | } 634 | for _, tt := range tests { 635 | t.Run(tt.name, func(t *testing.T) { 636 | if got := isExcludedAuthor(tt.args.authorList, tt.args.authorToCheck); got != tt.want { 637 | t.Errorf("isExcludedAuthor() = %v, want %v", got, tt.want) 638 | } 639 | }) 640 | } 641 | } 642 | 643 | func Test_prettyPrintStringList(t *testing.T) { 644 | type args struct { 645 | listToPrint []string 646 | } 647 | tests := []struct { 648 | name string 649 | args args 650 | want string 651 | }{ 652 | { 653 | "Empty list", 654 | args{listToPrint: []string{}}, 655 | "[ (empty) ]", 656 | }, 657 | { 658 | "single item", 659 | args{listToPrint: []string{"user1"}}, 660 | "[ 'user1' ]", 661 | }, 662 | { 663 | "multiple items", 664 | args{listToPrint: []string{"user1", "user2"}}, 665 | "[ 'user1', 'user2' ]", 666 | }, 667 | } 668 | for _, tt := range tests { 669 | t.Run(tt.name, func(t *testing.T) { 670 | if got := prettyPrintStringList(tt.args.listToPrint); got != tt.want { 671 | t.Errorf("prettyPrintStringList() = %v, want %v", got, tt.want) 672 | } 673 | }) 674 | } 675 | } 676 | 677 | func Test_isValidDir(t *testing.T) { 678 | type args struct { 679 | dirName string 680 | } 681 | tests := []struct { 682 | name string 683 | args args 684 | want bool 685 | }{ 686 | { 687 | "happy case", 688 | args{dirName: "../Notes"}, 689 | true, 690 | }, 691 | { 692 | "non existent dir", 693 | args{dirName: "../undefined"}, 694 | false, 695 | }, 696 | { 697 | "not a directory", 698 | args{dirName: "../test-data/empty-submission-list.csv"}, 699 | false, 700 | }, 701 | } 702 | for _, tt := range tests { 703 | t.Run(tt.name, func(t *testing.T) { 704 | if got := isValidDir(tt.args.dirName); got != tt.want { 705 | t.Errorf("isValidDir() = %v, want %v", got, tt.want) 706 | } 707 | }) 708 | } 709 | } 710 | --------------------------------------------------------------------------------