├── .github └── workflows │ ├── ci.yaml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd ├── jobs.go ├── root.go └── stats.go ├── go.mod ├── go.sum ├── images └── demo.gif ├── internal ├── github │ ├── auth.go │ ├── auth_test.go │ ├── jobs.go │ ├── model.go │ └── runs.go ├── parser │ ├── calculate.go │ ├── calculate_test.go │ ├── jobs.go │ ├── jobs_test.go │ ├── runs.go │ ├── runs_test.go │ └── testdata │ │ ├── jobs │ │ ├── empty.json │ │ ├── multiple-jobs.json │ │ └── multiple-success.json │ │ └── runs │ │ ├── empty.json │ │ ├── failure-others.json │ │ ├── multiple-conclusions.json │ │ └── success.json └── printer │ ├── jobs.go │ ├── jobs_test.go │ ├── runs.go │ ├── runs_test.go │ ├── spinner.go │ └── warning.go ├── main.go ├── renovate.json └── sample ├── json-output.json └── std-output.txt /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "main.go" 9 | - "internal/**" 10 | - "cmd/**" 11 | - ".github/workflows/ci.yml" 12 | pull_request: 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version-file: "./go.mod" 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v6 24 | with: 25 | version: latest 26 | # TODO: vaild nilaway after refactoring 27 | # - name: install nilaway 28 | # run: go install go.uber.org/nilaway/cmd/nilaway@latest 29 | # - name: run nilaway 30 | # run: nilaway -include-pkgs="github.com/fchimpan/gh-workflow-stats" ./... 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-go@v5 37 | with: 38 | go-version-file: "./go.mod" 39 | - name: go test 40 | run: go test -v ./... 41 | 42 | build: 43 | runs-on: ubuntu-latest 44 | env: 45 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-go@v5 49 | with: 50 | go-version-file: "./go.mod" 51 | - name: go build 52 | run: go build . 53 | - name: gh install 54 | run: gh extension install . 55 | - name: test execute gh workflow-stats standard 56 | run: gh workflow-stats jobs -o fchimpan -r gh-workflow-stats -f ci.yaml 57 | - name: test execute gh workflow-stats json 58 | run: gh workflow-stats jobs -o fchimpan -r gh-workflow-stats -f ci.yaml --json 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: cli/gh-extension-precompile@v2 15 | with: 16 | go_version_file: go.mod 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /workflow-stats 2 | /workflow-stats.exe 3 | 4 | /scraps 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ryo Mimura 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow stats 2 | 3 | ✨ A GitHub CLI extension to calculate the success rate and execution time of workflows and jobs. 4 | 5 | ![demo.gif](./images/demo.gif) 6 | - Calculate the success rate and execution time of workflow runs 7 | - Average, median, minimum, and maximum execution time of successful runs 8 | - Detailed each runs 9 | - Fetch the workflow runs and workflow runs attempts 10 | - Run ID, Actor, Started At, Duration(seconds), HTML URL 11 | - rate of success, failure, and others outcomes 12 | - Calculate the success rate and execution time of workflow job steps 13 | - Average, median, minimum, and maximum execution time of successful jobs 14 | - Detailed each jobs 15 | - rate of success, failure, and others outcomes for each step 16 | - Detailed each steps 17 | - Step name, Step number, Runs count, Conclusion, Rate, Execution duration, Failure HTML URL 18 | - Most failed steps 19 | - Most time-consuming steps 20 | - This tool can use composite action and reusable workflow. 21 | 22 | 23 | ## Installation 24 | 25 | You can install it using the gh cli extension. 26 | 27 | ``` 28 | gh extensions install fchimpan/gh-workflow-stats 29 | ``` 30 | 31 | ## Quick Start 32 | 33 | If you want to get the success rate and execution time of a workflow: 34 | 35 | ```sh 36 | $ gh workflow-stats -o $OWNER -r $REPO -f ci.yaml 37 | ``` 38 | 39 | If you want to get the success rate and execution time of a workflow job in JSON format: 40 | 41 | ```sh 42 | $ gh workflow-stats jobs -o $OWNER -r $REPO -f ci.yaml --json 43 | ``` 44 | 45 | ## Usage 46 | 47 | ```sh 48 | $ gh workflow-stats -h 49 | Get workflow runs stats. Retrieve the success rate and execution time of workflows. 50 | 51 | Usage: 52 | workflow-stats [flags] 53 | workflow-stats [command] 54 | 55 | Examples: 56 | $ gh workflow-stats --org $OWNER --repo $REPO -f ci.yaml 57 | 58 | Available Commands: 59 | completion Generate the autocompletion script for the specified shell 60 | help Help about any command 61 | jobs Fetch workflow jobs stats. Retrieve the steps and jobs success rate. 62 | 63 | Flags: 64 | -a, --actor string Workflow run actor 65 | -A, --all Target all workflows in the repository. If specified, default fetches of 100 workflow runs is overridden to all workflow runs. Note the GitHub API rate limit. 66 | -b, --branch string Workflow run branch. Returns workflow runs associated with a branch. Use the name of the branch of the push. 67 | -C, --check-suite-id int Workflow run check suite ID 68 | -c, --created string Workflow run createdAt. Returns workflow runs created within the given date-time range. 69 | For more information on the syntax, see https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates 70 | -e, --event string Workflow run event. e.g. push, pull_request, pull_request_target, etc. 71 | See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows 72 | -x, --exclude-pull-requests Workflow run exclude pull requests 73 | -f, --file string The name of the workflow file. e.g. ci.yaml. You can also pass the workflow id as a integer. 74 | -S, --head-sha string Workflow run head SHA 75 | -h, --help help for workflow-stats 76 | -H, --host string GitHub host. If not specified, default is github.com. If you want to use GitHub Enterprise Server, specify your GitHub Enterprise Server host. (default "github.com") 77 | -i, --id int The ID of the workflow. You can also pass the workflow file name as a string. (default -1) 78 | --json Output as JSON 79 | -o, --org string GitHub organization 80 | -r, --repo string GitHub repository 81 | -s, --status string Workflow run status. e.g. completed, in_progress, queued, etc. 82 | See https://docs.github.com/en/rest/reference/actions#list-workflow-runs-for-a-repository 83 | 84 | Use "workflow-stats [command] --help" for more information about a command. 85 | ``` 86 | 87 | ### Fetch workflow ID 88 | 89 | For the `--id` flag, you can list the workflow IDs with the standard `gh` CLI: `gh workflow list`, e.g.: 90 | ``` 91 | NAME STATE ID 92 | AAAA active 123456 93 | BBBB active 543112 94 | ``` 95 | 96 | ### Fetch all workflow runs 97 | 98 | By default, `workflow-stats` and `workflow-stats jobs` commands will return **100** workflow runs. 99 | If you want to get all workflow runs, you can use the `--all` flag. 100 | However, GitHub API has a rate limit, so please combine other parameters as appropriate to reduce the number of API requests. 101 | 102 | ```sh 103 | # Get executed at 2022-01-01 or later and actor is $ACTOR 104 | $ gh workflow-stats -o $OWNER -r $REPO -f ci.yaml -A -c ">2024-01-01" -a $ACTOR 105 | ``` 106 | 107 | More details on API rate limits can be found in the [GitHub API documentation](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28). 108 | 109 | ### GitHub Enterprise Server 110 | 111 | If you want to use GitHub Enterprise Server, you can use the `--host` flag or set the `GH_HOST` environment variable. 112 | Host name is `http(s)://[your-github-host]/api/v3/` 113 | 114 | ```sh 115 | $ export GH_HOST="your-github-host" 116 | $ gh workflow-stats -o $OWNER -r $REPO -f ci.yaml 117 | # or 118 | $ gh workflow-stats -o $OWNER -r $REPO -f ci.yaml --host="your-github-host" 119 | ``` 120 | 121 | ### How it works 122 | This tool retrieves workflow execution statistics using the GitHub API. For more details on the parameters, please refer to the GitHub API reference. 123 | - [Workflow runs](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow) 124 | - [Workflow jobs](https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#get-a-job-for-a-workflow-run) 125 | 126 | ## Rate Limiting 127 | 128 | GitHub imposes a [primary rate limit](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-primary-rate-limits) and a [secondary rate limit](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-rate-limits) on all API clients. 129 | 130 | When you reach the primary rate limit, results are calculated based on successful fetches. 131 | In the secondary rate limit, this tool handles requests in a round-tripper to avoid the rate limit. Therefore, the execution time may be longer. 132 | 133 | ## Standard Output 134 | 135 | [Sample output](./sample/std-output.txt) 136 | 137 | ### 🏃 Total runs 138 | 139 | `Total runs` is the total number of workflow runs that `status` is `completed`. It includes `success`, `failure`, and `others` outcomes. `Others` outcomes include `cancelled`, `skipped`, etc. 140 | 141 | **`Total runs` includes the results of attempts other than the latest.** 142 | This means that for a workflow that succeeds on the third attempt, the results of the first and second workflow executions are also included in the calculation. 143 | 144 | ### ⏰ Workflow run execution time stats 145 | 146 | `Workflow run execution time stats` is the average execution time of workflows with **`success` conclusion and `completed` status**. 147 | 148 | ### 📈 Top 3 jobs with the highest failure counts (failure runs / total runs) 149 | 150 | `Top 3 jobs with the highest failure counts` is the top 3 jobs with the highest failure counts. It is **not** failure rate. 151 | Instead of using the success rate, the tool displays jobs based on the number of failures, as jobs with fewer executions but a high failure rate would otherwise be ranked higher. 152 | 153 | The number of jobs displayed can be changed with a command-line argument `-n`. 154 | 155 | 156 | ### 📊 Top 3 jobs with the longest execution average duration 157 | 158 | `Top 3 jobs with the longest execution average duration` is the top 3 jobs with the longest execution average duration. 159 | 160 | The number of jobs displayed can be changed with a command-line argument `-n`. 161 | 162 | ## JSON Schema Overview 163 | 164 | If you use `--json` flag, the output will be a JSON object with the following structure. You can see sample output in [json-output.json](./sample/json-output.json). 165 | 166 | ### Workflow runs 167 | 168 | | Field Name | Type | Description | 169 | | ----------------------------- | ---------------- | --------------------------------------------------------------- | 170 | | `workflow_runs_stats_summary` | Object | An object containing a summary of statistics for workflow runs. | 171 | | `workflow_jobs_stats_summary` | Array of objects | An array containing the summary statistics for workflow jobs. | 172 | 173 | #### `workflow_runs_stats_summary` Object 174 | 175 | | Field Name | Type | Description | 176 | | -------------------------- | ------- | ------------------------------------------------------------------- | 177 | | `total_runs_count` | Integer | The total number of workflow runs. | 178 | | `name` | String | The name of the workflow. | 179 | | `rate` | Object | An object containing rates of success, failure, and other outcomes. | 180 | | `execution_duration_stats` | Object | An object containing statistics on execution durations. | 181 | | `conclusions` | Object | An object containing detailed information for each conclusion type. | 182 | 183 | ##### `rate` Object 184 | 185 | | Field Name | Type | Description | 186 | | -------------- | ----- | --------------------------------------------------------------- | 187 | | `success_rate` | Float | The rate of success executions. | 188 | | `failure_rate` | Float | The rate of failed executions. | 189 | | `others_rate` | Float | The rate of others outcomes. e.g., `cancelled`, `skipped`, etc. | 190 | 191 | ##### `execution_duration_stats` Object 192 | 193 | `execution_duration_stats` is the average execution time of workflows with `success` conclusion and `completed` status. 194 | In `post steps`, the execution time may be incorrect because GitHub API does not provide the duration of the workflow run. 195 | 196 | | Field Name | Type | Description | 197 | | ---------- | ----- | -------------------------------------- | 198 | | `min` | Float | Minimum execution time in seconds. | 199 | | `max` | Float | Maximum execution time in seconds. | 200 | | `avg` | Float | Average execution time in seconds. | 201 | | `med` | Float | Median execution time in seconds. | 202 | | `std` | Float | Standard deviation of execution times. | 203 | 204 | ##### `conclusions` Object 205 | 206 | | Key | Description | 207 | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 208 | | `failure` | Statistics for executions concluded as `failure`. | 209 | | `others` | Statistics for executions that do not fit into the typical success/failure categories. e.g `cancelled`, `skipped`, etc. More details in [GitHub API documentation](https://docs.github.com/en/enterprise-server@3.10/graphql/reference/enums#checkconclusionstate). | 210 | | `success` | Statistics for executions concluded as `success`. | 211 | 212 | ##### Conclusion Objects (`failure`, `others`, `success`) 213 | 214 | | Field Name | Type | Description | 215 | | --------------- | ------- | ---------------------------------------- | 216 | | `runs_count` | Integer | The number of runs with this conclusion. | 217 | | `workflow_runs` | Array | An array of objects detailing each run. | 218 | 219 | ##### `workflow_runs` Objects 220 | 221 | | Field Name | Type | Description | 222 | | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 223 | | `id` | Integer | The ID of the run. | 224 | | `status` | String | The status of the run (e.g., `completed`, `queued`). | 225 | | `conclusion` | String | The conclusion of the run (e.g., `success`, `failure`). | 226 | | `actor` | String | The actor who initiated the run. | 227 | | `run_attempt` | Integer | The attempt number of the run. | 228 | | `html_url` | String | The HTML URL to the run on GitHub. | 229 | | `jobs_url` | String | The URL to the jobs of the run. | 230 | | `logs_url` | String | The URL to the logs of the run. | 231 | | `run_started_at` | DateTime | The start time of the run. | 232 | | `duration` | Integer | The duration of the run in seconds. Duration defined as `RunUpdatedAt` - `RunStartedAt`. **Note**: GitHub API is not provide duration. Thus, this may be not correct. | 233 | 234 | 235 | ### Workflow Jobs Object 236 | 237 | Each object in the `workflow_jobs_stats_summary` array contains the following fields: 238 | 239 | | Field Name | Type | Description | 240 | | -------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 241 | | `name` | String | The name of the workflow job. | 242 | | `total_runs_count` | Integer | The total number of runs for this job. | 243 | | `rate` | Object | An object containing the rates of success, failure, and others. | 244 | | `conclusions` | Object | An object containing the count of runs concluded as failure, success or others. | 245 | | `execution_duration_stats` | Object | An object with statistics about the execution duration. Duration defined as `GetStartedAt` - `CompletedAt`. **Note**: GitHub API is not provide duration. Thus, this may be not correct. | 246 | | `steps_summary` | Array of objects | An array with summary statistics for each step of the job. | 247 | 248 | ### Steps Summary Object 249 | 250 | Each object in the `steps_summary` array includes: 251 | 252 | | Field Name | Type | Description | 253 | | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 254 | | `name` | String | The name of the step. | 255 | | `number` | Integer | The step number in the job's sequence. | 256 | | `runs_count` | Integer | The number of times this step was run. | 257 | | `conclusion` | Object | An object containing the count of success, failure and others. | 258 | | `rate` | Object | An object containing the success rate, failure rate, and others rate for the step. | 259 | | `execution_duration_stats` | Object | An object with statistics about the execution duration for the step. Duration defined as `GetStartedAt` - `CompletedAt`. **Note**: GitHub API is not provide duration. Thus, this may be not correct. | 260 | | `failure_html_url` | Array | An array of URLs to logs of the failed runs, if any. | 261 | 262 | #### Conclusion Object for Step 263 | 264 | | Key | Description | 265 | | --------- | ----------------------------------------------------------------------------------------------------------------------- | 266 | | `failure` | Statistics for executions concluded as `failure`. | 267 | | `others` | Statistics for executions that do not fit into the typical success/failure categories. e.g `cancelled`, `skipped`, etc. | 268 | | `success` | Statistics for executions concluded as `success`. | 269 | 270 | 271 | ### Sample JSON Output 272 | 273 | [Sample output](./sample/json-output.json) 274 | 275 | ```json 276 | { 277 | "workflow_runs_stats_summary": { 278 | "total_runs_count": 100, 279 | "name": "Tests", 280 | "rate": { 281 | "success_rate": 0.79, 282 | "failure_rate": 0.14, 283 | "others_rate": 0.06999999999999995 284 | }, 285 | "execution_duration_stats": { 286 | "min": 365, 287 | "max": 1080, 288 | "avg": 505.54430379746833, 289 | "med": 464, 290 | "std": 120.8452088122228 291 | }, 292 | "conclusions": { 293 | "failure": { 294 | "runs_count": 14, 295 | "workflow_runs": [ 296 | { 297 | "id": 8178615249, 298 | "status": "completed", 299 | "conclusion": "failure", 300 | "actor": "XXX", 301 | "run_attempt": 1, 302 | "html_url": "https://github.com/xxx/xxx/actions/runs/8178615249", 303 | "run_started_at": "2024-03-06T20:53:43Z", 304 | "duration": 0 305 | }, 306 | ... 307 | ] 308 | }, 309 | } 310 | }, 311 | "workflow_jobs_stats_summary": [ 312 | { 313 | "name": "build (ubuntu-latest)", 314 | "total_runs_count": 93, 315 | "rate": { 316 | "success_rate": 0.8494623655913979, 317 | "failure_rate": 0.15053763440860216, 318 | "others_rate": 0 319 | }, 320 | "conclusions": { 321 | "failure": 14, 322 | "success": 79 323 | }, 324 | "execution_duration_stats": { 325 | "min": 166, 326 | "max": 225, 327 | "avg": 185.2405063291139, 328 | "med": 178, 329 | "std": 15.9367541045597 330 | }, 331 | "steps_summary": [ 332 | { 333 | "name": "Set up job", 334 | "number": 1, 335 | "runs_count": 93, 336 | "conclusion": { 337 | "success": 93 338 | }, 339 | "rate": { 340 | "success_rate": 1, 341 | "failure_rate": 0, 342 | "others_rate": 0 343 | }, 344 | "execution_duration_stats": { 345 | "min": 0, 346 | "max": 3, 347 | "avg": 1.3225806451612903, 348 | "med": 1, 349 | "std": 0.6584649846191345 350 | }, 351 | "failure_html_url": [] 352 | }, 353 | ... 354 | ] 355 | }, 356 | ... 357 | ] 358 | } 359 | ``` 360 | 361 | -------------------------------------------------------------------------------- /cmd/jobs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | numJobs int 12 | ) 13 | 14 | var jobsCmd = &cobra.Command{ 15 | Use: "jobs", 16 | Short: "Fetch workflow jobs stats. Retrieve the steps and jobs success rate.", 17 | Example: `$ gh workflow-stats jobs --org=OWNER --repo=REPO --id=WORKFLOW_ID`, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | if envHost := os.Getenv("GH_HOST"); envHost != "" && !cmd.Flags().Changed("host") { 20 | host = envHost 21 | } 22 | if org == "" || repo == "" { 23 | return fmt.Errorf("--org and --repo flag must be specified. If you want to use GitHub Enterprise Server, specify your GitHub Enterprise Server host with --host flag") 24 | } 25 | if fileName == "" && id == -1 { 26 | return fmt.Errorf("--file or --id flag must be specified") 27 | } 28 | if numJobs < 1 { 29 | numJobs = 1 30 | } 31 | return workflowStats(config{ 32 | host: host, 33 | org: org, 34 | repo: repo, 35 | workflowFileName: fileName, 36 | workflowID: id, 37 | }, options{ 38 | actor: actor, 39 | branch: branch, 40 | event: event, 41 | status: status, 42 | created: created, 43 | headSHA: headSHA, 44 | excludePullRequests: excludePullRequests, 45 | checkSuiteID: checkSuiteID, 46 | all: all, 47 | js: js, 48 | jobNum: numJobs, 49 | }, true) 50 | }, 51 | } 52 | 53 | func init() { 54 | rootCmd.AddCommand(jobsCmd) 55 | jobsCmd.Flags().IntVarP(&numJobs, "num-jobs", "n", 3, "Number of jobs to display") 56 | } 57 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | host string 12 | org string 13 | repo string 14 | fileName string 15 | id int64 16 | all bool 17 | js bool 18 | actor string 19 | branch string 20 | event string 21 | status []string 22 | created string 23 | headSHA string 24 | excludePullRequests bool 25 | checkSuiteID int64 26 | ) 27 | 28 | var rootCmd = &cobra.Command{ 29 | Use: "workflow-stats", 30 | Short: "Fetch workflow runs stats. Retrieve the success rate and execution time of workflows.", 31 | Example: `$ gh workflow-stats --org $OWNER --repo $REPO -f ci.yaml`, 32 | RunE: func(cmd *cobra.Command, _ []string) error { 33 | if envHost := os.Getenv("GH_HOST"); envHost != "" && !cmd.Flags().Changed("host") { 34 | host = envHost 35 | } 36 | if org == "" || repo == "" { 37 | return fmt.Errorf("--org and --repo flag must be specified. If you want to use GitHub Enterprise Server, specify your GitHub Enterprise Server host with --host flag") 38 | } 39 | if fileName == "" && id == -1 { 40 | return fmt.Errorf("--file or --id flag must be specified") 41 | } 42 | return workflowStats(config{ 43 | host: host, 44 | org: org, 45 | repo: repo, 46 | workflowFileName: fileName, 47 | workflowID: id, 48 | }, options{ 49 | actor: actor, 50 | branch: branch, 51 | event: event, 52 | status: status, 53 | created: created, 54 | headSHA: headSHA, 55 | excludePullRequests: excludePullRequests, 56 | checkSuiteID: checkSuiteID, 57 | all: all, 58 | js: js, 59 | }, false) 60 | }, 61 | } 62 | 63 | func Execute() { 64 | err := rootCmd.Execute() 65 | if err != nil { 66 | os.Exit(1) 67 | } 68 | } 69 | 70 | func init() { 71 | rootCmd.PersistentFlags().StringVarP(&host, "host", "H", "github.com", "GitHub host. If not specified, default is github.com. If you want to use GitHub Enterprise Server, specify your GitHub Enterprise Server host.") 72 | rootCmd.PersistentFlags().StringVarP(&org, "org", "o", "", "GitHub organization") 73 | rootCmd.PersistentFlags().StringVarP(&repo, "repo", "r", "", "GitHub repository") 74 | rootCmd.PersistentFlags().StringVarP(&fileName, "file", "f", "", "The name of the workflow file. e.g. ci.yaml. You can also pass the workflow id as a integer.") 75 | rootCmd.PersistentFlags().Int64VarP(&id, "id", "i", -1, "The ID of the workflow. You can also pass the workflow file name as a string.") 76 | rootCmd.PersistentFlags().BoolVarP(&all, "all", "A", false, "Target all workflows in the repository. If specified, default fetches of 100 workflow runs is overridden to all workflow runs. Note the GitHub API rate limit.") 77 | rootCmd.PersistentFlags().BoolVar(&js, "json", false, "Output as JSON") 78 | 79 | // Workflow runs query parameters 80 | // See https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow 81 | rootCmd.PersistentFlags().StringVarP(&actor, "actor", "a", "", "Workflow run actor") 82 | rootCmd.PersistentFlags().StringVarP(&branch, "branch", "b", "", "Workflow run branch. Returns workflow runs associated with a branch. Use the name of the branch of the push.") 83 | rootCmd.PersistentFlags().StringVarP(&event, "event", "e", "", "Workflow run event. e.g. push, pull_request, pull_request_target, etc.\n See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows") 84 | rootCmd.PersistentFlags().StringSliceVarP(&status, "status", "s", []string{""}, "Workflow run status. e.g. completed, in_progress, queued, etc.\n Multiple values can be provided separated by a comma. For a full list of supported values see https://docs.github.com/en/rest/reference/actions#list-workflow-runs-for-a-repository") 85 | rootCmd.PersistentFlags().StringVarP(&created, "created", "c", "", "Workflow run createdAt. Returns workflow runs created within the given date-time range.\n For more information on the syntax, see https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates") 86 | rootCmd.PersistentFlags().StringVarP(&headSHA, "head-sha", "S", "", "Workflow run head SHA") 87 | rootCmd.PersistentFlags().BoolVarP(&excludePullRequests, "exclude-pull-requests", "x", false, "Workflow run exclude pull requests") 88 | rootCmd.PersistentFlags().Int64VarP(&checkSuiteID, "check-suite-id", "C", 0, "Workflow run check suite ID") 89 | } 90 | -------------------------------------------------------------------------------- /cmd/stats.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "slices" 11 | 12 | "github.com/fchimpan/gh-workflow-stats/internal/github" 13 | "github.com/fchimpan/gh-workflow-stats/internal/parser" 14 | "github.com/fchimpan/gh-workflow-stats/internal/printer" 15 | 16 | go_github "github.com/google/go-github/v60/github" 17 | ) 18 | 19 | const ( 20 | workflowRunsText = " fetching workflow runs..." 21 | workflowJobsText = " fetching workflow jobs..." 22 | charSize = 14 23 | ) 24 | 25 | type config struct { 26 | host string 27 | org string 28 | repo string 29 | workflowFileName string 30 | workflowID int64 31 | } 32 | 33 | type options struct { 34 | actor string 35 | branch string 36 | event string 37 | status []string 38 | created string 39 | headSHA string 40 | excludePullRequests bool 41 | checkSuiteID int64 42 | all bool 43 | js bool 44 | jobNum int 45 | } 46 | 47 | func workflowStats(cfg config, opt options, isJobs bool) error { 48 | ctx := context.Background() 49 | w := io.Writer(os.Stdout) 50 | a := &github.GitHubAuthenticator{} 51 | client, err := github.NewClient(cfg.host, a) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | s, err := printer.NewSpinner(printer.SpinnerOptions{ 57 | Text: workflowRunsText, 58 | CharSetsIndex: charSize, 59 | Color: "green", 60 | }) 61 | if err != nil { 62 | return err 63 | } 64 | s.Start() 65 | defer s.Stop() 66 | 67 | isRateLimit := false 68 | runs, err := fetchWorkflowRuns(ctx, client, cfg, opt) 69 | if err != nil { 70 | if errors.As(err, &github.RateLimitError{}) { 71 | isRateLimit = true 72 | } else { 73 | return err 74 | } 75 | } 76 | 77 | var jobs []*parser.WorkflowJobsStatsSummary 78 | if isJobs { 79 | s.Update(printer.SpinnerOptions{ 80 | Text: workflowJobsText, 81 | CharSetsIndex: charSize, 82 | Color: "pink", 83 | }) 84 | j, err := client.FetchWorkflowJobsAttempts(ctx, runs, &github.WorkflowRunsConfig{ 85 | Org: cfg.org, 86 | Repo: cfg.repo, 87 | }) 88 | if err != nil { 89 | if errors.As(err, &github.RateLimitError{}) { 90 | isRateLimit = true 91 | } else { 92 | return err 93 | } 94 | } 95 | jobs = parser.WorkflowJobsParse(j) 96 | } 97 | 98 | s.Stop() 99 | 100 | wrs := parser.WorkflowRunsParse(runs) 101 | 102 | if opt.js { 103 | res := &parser.Result{ 104 | WorkflowRunsStatsSummary: wrs, 105 | WorkflowJobsStatsSummary: []*parser.WorkflowJobsStatsSummary{}, 106 | } 107 | if isJobs { 108 | res.WorkflowJobsStatsSummary = jobs 109 | } 110 | bytes, err := json.MarshalIndent(res, "", " ") 111 | if err != nil { 112 | return err 113 | } 114 | fmt.Println(string(bytes)) 115 | } else { 116 | if isRateLimit { 117 | printer.RateLimitWarning(os.Stdout) 118 | } 119 | printer.Runs(w, wrs) 120 | if isJobs { 121 | printer.FailureJobs(w, jobs, opt.jobNum) 122 | printer.LongestDurationJobs(w, jobs, opt.jobNum) 123 | } 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func fetchWorkflowRuns(ctx context.Context, client *github.WorkflowStatsClient, cfg config, opt options) ([]*go_github.WorkflowRun, error) { 130 | // Intentionally not using Github API status filter as it applies only to the last run attempt. 131 | // Instead retrieving all qualifying workflow runs and their run attempts and filtering by status manually (if needed) 132 | runs, err := client.FetchWorkflowRuns(ctx, &github.WorkflowRunsConfig{ 133 | Org: cfg.org, 134 | Repo: cfg.repo, 135 | WorkflowFileName: cfg.workflowFileName, 136 | WorkflowID: cfg.workflowID, 137 | }, &github.WorkflowRunsOptions{ 138 | All: opt.all, 139 | Actor: opt.actor, 140 | Branch: opt.branch, 141 | Event: opt.event, 142 | Status: "", 143 | Created: opt.created, 144 | HeadSHA: opt.headSHA, 145 | ExcludePullRequests: opt.excludePullRequests, 146 | CheckSuiteID: opt.checkSuiteID, 147 | }, 148 | ) 149 | if err == nil { 150 | return filterRunAttemptsByStatus(runs, opt.status), nil 151 | } else { 152 | return nil, err 153 | } 154 | } 155 | 156 | func filterRunAttemptsByStatus(runs []*go_github.WorkflowRun, status []string) []*go_github.WorkflowRun { 157 | if len(status) == 0 || (len(status) == 1 && status[0] == "") { 158 | return runs 159 | } 160 | filteredRuns := []*go_github.WorkflowRun{} 161 | for _, r := range runs { 162 | if (r.Status != nil && slices.Contains(status, *r.Status)) || (r.Conclusion != nil && slices.Contains(status, *r.Conclusion)) { 163 | filteredRuns = append(filteredRuns, r) 164 | } 165 | } 166 | return filteredRuns 167 | } 168 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fchimpan/gh-workflow-stats 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/briandowns/spinner v1.23.2 7 | github.com/cli/go-gh/v2 v2.12.1 8 | github.com/fatih/color v1.18.0 9 | github.com/gofri/go-github-ratelimit v1.1.1 10 | github.com/google/go-github/v60 v60.0.0 11 | github.com/montanaflynn/stats v0.7.1 12 | github.com/spf13/cobra v1.9.1 13 | github.com/stretchr/testify v1.10.0 14 | ) 15 | 16 | require ( 17 | github.com/cli/safeexec v1.0.0 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/google/go-querystring v1.1.0 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/kr/pretty v0.3.1 // indirect 22 | github.com/mattn/go-colorable v0.1.13 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | github.com/spf13/pflag v1.0.6 // indirect 26 | github.com/stretchr/objx v0.5.2 // indirect 27 | golang.org/x/sys v0.31.0 // indirect 28 | golang.org/x/term v0.30.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 2 | github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 3 | github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA= 4 | github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= 5 | github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= 6 | github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 12 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 13 | github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0= 14 | github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM= 15 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= 19 | github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= 20 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 21 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 22 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 23 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 24 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 29 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 32 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 33 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= 34 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 35 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 39 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 40 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 42 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 43 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 44 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 45 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 46 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 47 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 48 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 52 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 53 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 54 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 55 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 56 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 58 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 60 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fchimpan/gh-workflow-stats/3ef0cee43dfae6fff9c7a468f4ca21567574c509/images/demo.gif -------------------------------------------------------------------------------- /internal/github/auth.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cli/go-gh/v2/pkg/auth" 7 | "github.com/gofri/go-github-ratelimit/github_ratelimit" 8 | "github.com/google/go-github/v60/github" 9 | ) 10 | 11 | type Authenticator interface { 12 | AuthTokenForHost(host string) (string, error) 13 | } 14 | 15 | type WorkflowStatsClient struct { 16 | client *github.Client 17 | authenticator Authenticator 18 | } 19 | 20 | type GitHubAuthenticator struct{} 21 | 22 | func (ga *GitHubAuthenticator) AuthTokenForHost(host string) (string, error) { 23 | token, _ := auth.TokenForHost(host) 24 | if token == "" { 25 | return "", fmt.Errorf("gh auth token not found for host %s", host) 26 | } 27 | return token, nil 28 | } 29 | 30 | func NewClient(host string, authenticator Authenticator) (*WorkflowStatsClient, error) { 31 | token, err := authenticator.AuthTokenForHost(host) 32 | if err != nil { 33 | return nil, err 34 | } 35 | r, err := github_ratelimit.NewRateLimitWaiterClient(nil) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | client := github.NewClient(r).WithAuthToken(token) 41 | if host != "github.com" { 42 | client.BaseURL.Host = host 43 | client.BaseURL.Path = "/api/v3/" 44 | } 45 | return &WorkflowStatsClient{client: client, authenticator: authenticator}, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/github/auth_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | type MockAuthenticator struct { 11 | mock.Mock 12 | } 13 | 14 | func (m *MockAuthenticator) AuthTokenForHost(host string) (string, error) { 15 | args := m.Called(host) 16 | return args.String(0), args.Error(1) 17 | } 18 | 19 | func TestNewClient(t *testing.T) { 20 | mockAuth := new(MockAuthenticator) 21 | mockAuth.On("AuthTokenForHost", mock.Anything).Return("dummy-token", nil) 22 | 23 | tests := []struct { 24 | name string 25 | host string 26 | wantErr bool 27 | }{ 28 | { 29 | name: "Default host", 30 | host: "github.com", 31 | wantErr: false, 32 | }, 33 | { 34 | name: "Custom host", 35 | host: "custom.com", 36 | wantErr: false, 37 | }, 38 | } 39 | 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | client, err := NewClient(tt.host, mockAuth) 43 | if (err != nil) != tt.wantErr { 44 | t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) 45 | return 46 | } 47 | if client == nil { 48 | t.Errorf("Expected non-nil client") 49 | return 50 | } 51 | assert.NotNil(t, client.client, "The github client should not be nil") 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/github/jobs.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "runtime" 8 | "sync" 9 | 10 | "github.com/google/go-github/v60/github" 11 | ) 12 | 13 | func (c *WorkflowStatsClient) FetchWorkflowJobsAttempts(ctx context.Context, runs []*github.WorkflowRun, cfg *WorkflowRunsConfig) ([]*github.WorkflowJob, error) { 14 | if len(runs) == 0 { 15 | return []*github.WorkflowJob{}, nil 16 | } 17 | jobsCh := make(chan []*github.WorkflowJob, len(runs)) 18 | errCh := make(chan error, len(runs)) 19 | 20 | // TODO: Semaphore to limit the number of concurrent requests. It's a assumption, not accurate. 21 | sem := make(chan struct{}, runtime.NumCPU()*8) 22 | var wg sync.WaitGroup 23 | wg.Add(len(runs)) 24 | for _, run := range runs { 25 | sem <- struct{}{} 26 | 27 | go func(run *github.WorkflowRun) { 28 | defer func() { 29 | <-sem 30 | wg.Done() 31 | }() 32 | jobs, resp, err := c.client.Actions.ListWorkflowJobsAttempt(ctx, cfg.Org, cfg.Repo, run.GetID(), int64(run.GetRunAttempt()), &github.ListOptions{ 33 | PerPage: perPage, 34 | }, 35 | ) 36 | if _, ok := err.(*github.RateLimitError); ok { 37 | errCh <- RateLimitError{Err: err} 38 | } else if err != nil && resp.StatusCode != http.StatusNotFound { 39 | errCh <- err 40 | } 41 | if jobs == nil || jobs.Jobs == nil || len(jobs.Jobs) == 0 { 42 | return 43 | } 44 | jobsCh <- jobs.Jobs 45 | 46 | }(run) 47 | } 48 | wg.Wait() 49 | close(jobsCh) 50 | close(errCh) 51 | 52 | var err error 53 | for e := range errCh { 54 | if e != nil { 55 | if errors.As(e, &RateLimitError{}) { 56 | err = e 57 | } else { 58 | return nil, e 59 | } 60 | } 61 | } 62 | 63 | allJobs := make([]*github.WorkflowJob, 0, len(runs)) 64 | for jobs := range jobsCh { 65 | allJobs = append(allJobs, jobs...) 66 | } 67 | return allJobs, err 68 | } 69 | -------------------------------------------------------------------------------- /internal/github/model.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | type RateLimitError struct { 4 | Err error 5 | } 6 | 7 | func (e RateLimitError) Error() string { 8 | return e.Err.Error() 9 | } 10 | -------------------------------------------------------------------------------- /internal/github/runs.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/google/go-github/v60/github" 9 | ) 10 | 11 | const perPage = 100 12 | 13 | type WorkflowRunsConfig struct { 14 | Org string 15 | Repo string 16 | WorkflowFileName string 17 | WorkflowID int64 18 | } 19 | 20 | type WorkflowRunsOptions struct { 21 | Actor string 22 | Branch string 23 | Event string 24 | Status string 25 | Created string 26 | HeadSHA string 27 | ExcludePullRequests bool 28 | CheckSuiteID int64 29 | All bool 30 | } 31 | 32 | func (c *WorkflowStatsClient) FetchWorkflowRuns(ctx context.Context, cfg *WorkflowRunsConfig, opt *WorkflowRunsOptions) ([]*github.WorkflowRun, error) { 33 | o := &github.ListWorkflowRunsOptions{ 34 | ListOptions: github.ListOptions{ 35 | PerPage: perPage, 36 | }, 37 | Actor: opt.Actor, 38 | Branch: opt.Branch, 39 | Event: opt.Event, 40 | Status: opt.Status, 41 | Created: opt.Created, 42 | HeadSHA: opt.HeadSHA, 43 | ExcludePullRequests: opt.ExcludePullRequests, 44 | CheckSuiteID: opt.CheckSuiteID, 45 | } 46 | 47 | initRuns, resp, err := c.listWorkflowRuns(ctx, cfg, o) 48 | if err != nil { 49 | if _, ok := err.(*github.RateLimitError); ok { 50 | return nil, RateLimitError{Err: err} 51 | } 52 | return nil, err 53 | } 54 | 55 | w := make([]*github.WorkflowRun, 0, initRuns.GetTotalCount()*2) 56 | w = append(w, initRuns.WorkflowRuns...) 57 | if resp.FirstPage == resp.LastPage || !opt.All { 58 | for _, run := range initRuns.WorkflowRuns { 59 | for a := 1; a < run.GetRunAttempt(); a++ { 60 | r, resp, err := c.client.Actions.GetWorkflowRunAttempt(ctx, cfg.Org, cfg.Repo, run.GetID(), a, &github.WorkflowRunAttemptOptions{ 61 | ExcludePullRequests: &opt.ExcludePullRequests, 62 | }) 63 | if _, ok := err.(*github.RateLimitError); ok { 64 | return w, RateLimitError{Err: err} 65 | } else if err != nil && resp.StatusCode != http.StatusNotFound { 66 | return nil, err 67 | } 68 | w = append(w, r) 69 | } 70 | } 71 | return w, nil 72 | } 73 | 74 | var wg sync.WaitGroup 75 | wg.Add(resp.LastPage) 76 | runsCh := make(chan []*github.WorkflowRun, *initRuns.TotalCount*10) 77 | errCh := make(chan error, resp.LastPage) 78 | 79 | for i := resp.FirstPage + 1; i <= resp.LastPage; i++ { 80 | go func(i int) { 81 | defer wg.Done() 82 | 83 | runs, resp, err := c.listWorkflowRuns(ctx, cfg, &github.ListWorkflowRunsOptions{ 84 | ListOptions: github.ListOptions{ 85 | Page: i, 86 | PerPage: perPage, 87 | }, 88 | Actor: opt.Actor, 89 | Branch: opt.Branch, 90 | Event: opt.Event, 91 | Status: opt.Status, 92 | Created: opt.Created, 93 | HeadSHA: opt.HeadSHA, 94 | ExcludePullRequests: opt.ExcludePullRequests, 95 | CheckSuiteID: opt.CheckSuiteID, 96 | }) 97 | if _, ok := err.(*github.RateLimitError); ok { 98 | errCh <- RateLimitError{Err: err} 99 | return 100 | } else if err != nil && resp.StatusCode != http.StatusNotFound { 101 | errCh <- err 102 | } 103 | w := make([]*github.WorkflowRun, 0, runs.GetTotalCount()*2) 104 | w = append(w, runs.WorkflowRuns...) 105 | for _, run := range runs.WorkflowRuns { 106 | for a := 1; a < run.GetRunAttempt(); a++ { 107 | r, resp, err := c.client.Actions.GetWorkflowRunAttempt(ctx, cfg.Org, cfg.Repo, run.GetID(), a, nil) 108 | if _, ok := err.(*github.RateLimitError); ok { 109 | errCh <- RateLimitError{Err: err} 110 | return 111 | } else if err != nil && resp.StatusCode != http.StatusNotFound { 112 | errCh <- err 113 | } 114 | w = append(w, r) 115 | } 116 | } 117 | runsCh <- w 118 | }(i) 119 | } 120 | wg.Wait() 121 | close(runsCh) 122 | close(errCh) 123 | 124 | for e := range errCh { 125 | if e != nil { 126 | return nil, e 127 | } 128 | } 129 | allRuns := make([]*github.WorkflowRun, 0, *initRuns.TotalCount) 130 | for runs := range runsCh { 131 | allRuns = append(allRuns, runs...) 132 | } 133 | return allRuns, nil 134 | } 135 | 136 | func (c *WorkflowStatsClient) listWorkflowRuns(ctx context.Context, cfg *WorkflowRunsConfig, opt *github.ListWorkflowRunsOptions) (*github.WorkflowRuns, *github.Response, error) { 137 | if cfg.WorkflowFileName != "" { 138 | return c.client.Actions.ListWorkflowRunsByFileName(ctx, cfg.Org, cfg.Repo, cfg.WorkflowFileName, opt) 139 | } else { 140 | return c.client.Actions.ListWorkflowRunsByID(ctx, cfg.Org, cfg.Repo, cfg.WorkflowID, opt) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/parser/calculate.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/montanaflynn/stats" 7 | ) 8 | 9 | const eps = 1e-9 10 | 11 | type ExecutionDurationStats struct { 12 | Min float64 `json:"min"` 13 | Max float64 `json:"max"` 14 | Avg float64 `json:"avg"` 15 | Med float64 `json:"med"` 16 | Std float64 `json:"std"` 17 | } 18 | 19 | func calcStats(d []float64) ExecutionDurationStats { 20 | if len(d) == 0 { 21 | return ExecutionDurationStats{} 22 | } 23 | min := slices.Min(d) 24 | max := slices.Max(d) 25 | avg, _ := stats.Mean(d) 26 | med, _ := stats.Median(d) 27 | std, _ := stats.StandardDeviation(d) 28 | 29 | return ExecutionDurationStats{ 30 | Min: min, 31 | Max: max, 32 | Avg: avg, 33 | Med: med, 34 | Std: std, 35 | } 36 | 37 | } 38 | 39 | func adjustRate(rate float64) float64 { 40 | if rate < eps { 41 | return 0 42 | } 43 | return rate 44 | } 45 | -------------------------------------------------------------------------------- /internal/parser/calculate_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCalcStats(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input []float64 13 | expected ExecutionDurationStats 14 | }{ 15 | { 16 | name: "Empty input", 17 | input: []float64{}, 18 | expected: ExecutionDurationStats{}, 19 | }, 20 | { 21 | name: "Non-empty input", 22 | input: []float64{1.5, 2.5, 3.5, 4.5, 5.5}, 23 | expected: ExecutionDurationStats{Min: 1.5, Max: 5.5, Avg: 3.5, Med: 3.5, Std: 1.4142135623730951}, 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | result := calcStats(tt.input) 30 | assert.Equal(t, tt.expected, result) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/parser/jobs.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/google/go-github/v60/github" 7 | ) 8 | 9 | type Result struct { 10 | WorkflowRunsStatsSummary *WorkflowRunsStatsSummary `json:"workflow_runs_stats_summary"` 11 | WorkflowJobsStatsSummary []*WorkflowJobsStatsSummary `json:"workflow_jobs_stats_summary"` 12 | } 13 | 14 | type WorkflowJobsStatsSummary struct { 15 | Name string `json:"name"` 16 | TotalRunsCount int `json:"total_runs_count"` 17 | Rate Rate `json:"rate"` 18 | Conclusions map[string]int `json:"conclusions"` 19 | ExecutionDurationStats ExecutionDurationStats `json:"execution_duration_stats"` 20 | StepSummary []*StepSummary `json:"steps_summary"` 21 | } 22 | 23 | type StepSummary struct { 24 | Name string `json:"name"` 25 | Number int64 `json:"number"` 26 | RunsCount int `json:"runs_count"` 27 | Conclusions map[string]int `json:"conclusion"` 28 | Rate Rate `json:"rate"` 29 | ExecutionDurationStats ExecutionDurationStats `json:"execution_duration_stats"` 30 | FailureHTMLURL []string `json:"failure_html_url"` 31 | } 32 | 33 | type WorkflowJobsStatsSummaryCalc struct { 34 | TotalRunsCount int 35 | Name string 36 | Rate Rate 37 | Conclusions map[string]int 38 | ExecutionWorkflowDuration []float64 39 | StepSummary map[string]*StepSummaryCalc 40 | } 41 | 42 | type StepSummaryCalc struct { 43 | Name string 44 | Number int64 45 | RunsCount int 46 | Conclusions map[string]int 47 | StepDuration []float64 48 | FailureHTMLURL []string 49 | } 50 | 51 | func WorkflowJobsParse(wjs []*github.WorkflowJob) []*WorkflowJobsStatsSummary { 52 | if len(wjs) == 0 { 53 | return []*WorkflowJobsStatsSummary{} 54 | } 55 | 56 | m := make(map[string]*WorkflowJobsStatsSummaryCalc) 57 | for _, wj := range wjs { 58 | if _, ok := m[wj.GetName()]; !ok { 59 | m[wj.GetName()] = &WorkflowJobsStatsSummaryCalc{ 60 | TotalRunsCount: 0, 61 | Name: wj.GetName(), 62 | Rate: Rate{}, 63 | Conclusions: map[string]int{ 64 | ConclusionSuccess: 0, 65 | ConclusionFailure: 0, 66 | ConclusionOthers: 0, 67 | }, 68 | ExecutionWorkflowDuration: []float64{}, 69 | StepSummary: make(map[string]*StepSummaryCalc), 70 | } 71 | } 72 | w := m[wj.GetName()] 73 | w.TotalRunsCount++ 74 | c := wj.GetConclusion() 75 | if c != ConclusionSuccess && c != ConclusionFailure { 76 | c = ConclusionOthers 77 | } 78 | w.Conclusions[c]++ 79 | 80 | if wj.GetStatus() == StatusCompleted && c == ConclusionSuccess { 81 | d := wj.GetCompletedAt().Sub(wj.GetStartedAt().Time).Seconds() 82 | if d < 0 { 83 | d = 0 84 | } 85 | w.ExecutionWorkflowDuration = append(w.ExecutionWorkflowDuration, d) 86 | } 87 | 88 | for _, s := range wj.Steps { 89 | if _, ok := w.StepSummary[s.GetName()]; !ok { 90 | w.StepSummary[s.GetName()] = &StepSummaryCalc{ 91 | Name: s.GetName(), 92 | Number: s.GetNumber(), 93 | RunsCount: 0, 94 | Conclusions: map[string]int{ 95 | ConclusionSuccess: 0, 96 | ConclusionFailure: 0, 97 | ConclusionOthers: 0, 98 | }, 99 | FailureHTMLURL: []string{}, 100 | StepDuration: []float64{}, 101 | } 102 | } 103 | ss := w.StepSummary[s.GetName()] 104 | ss.RunsCount++ 105 | c := s.GetConclusion() 106 | if c != ConclusionSuccess && c != ConclusionFailure { 107 | c = ConclusionOthers 108 | } 109 | ss.Conclusions[c]++ 110 | if s.GetStatus() == StatusCompleted && c == ConclusionFailure { 111 | ss.FailureHTMLURL = append(ss.FailureHTMLURL, wj.GetHTMLURL()) 112 | } 113 | if s.GetStatus() == StatusCompleted && (c == ConclusionSuccess || c == ConclusionFailure) { 114 | d := s.GetCompletedAt().Sub(s.GetStartedAt().Time).Seconds() 115 | if d < 0 { 116 | d = 0 117 | } 118 | ss.StepDuration = append(ss.StepDuration, d) 119 | } 120 | w.StepSummary[s.GetName()] = ss 121 | } 122 | m[wj.GetName()] = w 123 | } 124 | 125 | res := make([]*WorkflowJobsStatsSummary, 0, len(m)) 126 | for _, w := range m { 127 | wjs := &WorkflowJobsStatsSummary{ 128 | Name: w.Name, 129 | TotalRunsCount: w.TotalRunsCount, 130 | Rate: Rate{}, 131 | Conclusions: w.Conclusions, 132 | StepSummary: make([]*StepSummary, 0, len(w.StepSummary)), 133 | ExecutionDurationStats: calcStats(w.ExecutionWorkflowDuration), 134 | } 135 | for _, ss := range w.StepSummary { 136 | wjs.StepSummary = append(wjs.StepSummary, &StepSummary{ 137 | Name: ss.Name, 138 | Number: ss.Number, 139 | RunsCount: ss.RunsCount, 140 | Conclusions: ss.Conclusions, 141 | FailureHTMLURL: ss.FailureHTMLURL, 142 | ExecutionDurationStats: calcStats(ss.StepDuration), 143 | }) 144 | } 145 | 146 | wjs.Rate.SuccesRate = adjustRate(float64(w.Conclusions[ConclusionSuccess]) / max(float64(w.TotalRunsCount), 1)) 147 | wjs.Rate.FailureRate = adjustRate(float64(w.Conclusions[ConclusionFailure]) / max(float64(w.TotalRunsCount), 1)) 148 | wjs.Rate.OthersRate = adjustRate(max(float64(1-wjs.Rate.SuccesRate-wjs.Rate.FailureRate), 0)) 149 | res = append(res, wjs) 150 | } 151 | for _, r := range res { 152 | for _, s := range r.StepSummary { 153 | s.Rate.SuccesRate = adjustRate(float64(s.Conclusions[ConclusionSuccess]) / max(float64(s.RunsCount), 1)) 154 | s.Rate.FailureRate = adjustRate(float64(s.Conclusions[ConclusionFailure]) / max(float64(s.RunsCount), 1)) 155 | s.Rate.OthersRate = adjustRate(max(float64(1-s.Rate.SuccesRate-s.Rate.FailureRate), 0)) 156 | } 157 | 158 | sort.Slice(r.StepSummary, func(i, j int) bool { 159 | return r.StepSummary[i].Number < r.StepSummary[j].Number 160 | }) 161 | } 162 | 163 | return res 164 | } 165 | -------------------------------------------------------------------------------- /internal/parser/jobs_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/google/go-github/v60/github" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestWorkflowJobsParse(t *testing.T) { 13 | type args struct { 14 | file string 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want []*WorkflowJobsStatsSummary 20 | }{ 21 | { 22 | name: "Empty", 23 | args: args{ 24 | file: "empty.json", 25 | }, 26 | want: []*WorkflowJobsStatsSummary{}, 27 | }, 28 | { 29 | name: "Multiple success", 30 | args: args{ 31 | file: "multiple-success.json", 32 | }, 33 | want: []*WorkflowJobsStatsSummary{ 34 | { 35 | Name: "test", 36 | TotalRunsCount: 2, 37 | Rate: Rate{ 38 | SuccesRate: 1, 39 | FailureRate: 0, 40 | OthersRate: 0, 41 | }, 42 | Conclusions: map[string]int{ 43 | "success": 2, 44 | "failure": 0, 45 | "others": 0, 46 | }, 47 | ExecutionDurationStats: ExecutionDurationStats{ 48 | Min: 60, 49 | Max: 120, 50 | Avg: 90, 51 | Std: 30, 52 | Med: 90, 53 | }, 54 | StepSummary: []*StepSummary{ 55 | { 56 | Name: "Set up job", 57 | Number: 1, 58 | RunsCount: 2, 59 | Conclusions: map[string]int{ 60 | "success": 2, 61 | "failure": 0, 62 | "others": 0, 63 | }, 64 | Rate: Rate{ 65 | SuccesRate: 1, 66 | FailureRate: 0, 67 | OthersRate: 0, 68 | }, 69 | ExecutionDurationStats: ExecutionDurationStats{ 70 | Min: 10, 71 | Max: 20, 72 | Avg: 15, 73 | Std: 5, 74 | Med: 15, 75 | }, 76 | FailureHTMLURL: []string{}, 77 | }, 78 | { 79 | Name: "Run test", 80 | Number: 2, 81 | RunsCount: 2, 82 | Conclusions: map[string]int{ 83 | "success": 2, 84 | "failure": 0, 85 | "others": 0, 86 | }, 87 | Rate: Rate{ 88 | SuccesRate: 1, 89 | FailureRate: 0, 90 | OthersRate: 0, 91 | }, 92 | ExecutionDurationStats: ExecutionDurationStats{ 93 | Min: 50, 94 | Max: 100, 95 | Avg: 75, 96 | Std: 25, 97 | Med: 75, 98 | }, 99 | FailureHTMLURL: []string{}, 100 | }, 101 | { 102 | Name: "Complete job", 103 | Number: 3, 104 | RunsCount: 2, 105 | Conclusions: map[string]int{ 106 | "success": 2, 107 | "failure": 0, 108 | "others": 0, 109 | }, 110 | Rate: Rate{ 111 | SuccesRate: 1, 112 | FailureRate: 0, 113 | OthersRate: 0, 114 | }, 115 | ExecutionDurationStats: ExecutionDurationStats{ 116 | Min: 0, 117 | Max: 0, 118 | Avg: 0, 119 | Std: 0, 120 | Med: 0, 121 | }, 122 | 123 | FailureHTMLURL: []string{}, 124 | }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | { 130 | name: "Multiple jobs", 131 | args: args{ 132 | file: "multiple-jobs.json", 133 | }, 134 | want: []*WorkflowJobsStatsSummary{ 135 | { 136 | Name: "test", 137 | TotalRunsCount: 3, 138 | Rate: Rate{ 139 | SuccesRate: 0.3333333333333333, 140 | FailureRate: 0.3333333333333333, 141 | OthersRate: 0.3333333333333334, 142 | }, 143 | Conclusions: map[string]int{ 144 | "success": 1, 145 | "failure": 1, 146 | "others": 1, 147 | }, 148 | ExecutionDurationStats: ExecutionDurationStats{ 149 | Min: 60, 150 | Max: 60, 151 | Avg: 60, 152 | Std: 0, 153 | Med: 60, 154 | }, 155 | StepSummary: []*StepSummary{ 156 | { 157 | Name: "Set up job", 158 | Number: 1, 159 | RunsCount: 3, 160 | Conclusions: map[string]int{ 161 | "success": 3, 162 | "failure": 0, 163 | "others": 0, 164 | }, 165 | Rate: Rate{ 166 | SuccesRate: 1, 167 | FailureRate: 0, 168 | OthersRate: 0, 169 | }, 170 | ExecutionDurationStats: ExecutionDurationStats{ 171 | Min: 10, 172 | Max: 20, 173 | Avg: 16.666666666666668, 174 | Std: 4.714045207910316, 175 | Med: 20, 176 | }, 177 | FailureHTMLURL: []string{}, 178 | }, 179 | { 180 | Name: "Run test", 181 | Number: 2, 182 | RunsCount: 3, 183 | Conclusions: map[string]int{ 184 | "success": 1, 185 | "failure": 1, 186 | "others": 1, 187 | }, 188 | Rate: Rate{ 189 | SuccesRate: 0.3333333333333333, 190 | FailureRate: 0.3333333333333333, 191 | OthersRate: 0.3333333333333334, 192 | }, 193 | ExecutionDurationStats: ExecutionDurationStats{ 194 | Min: 50, 195 | Max: 100, 196 | Avg: 75, 197 | Std: 25, 198 | Med: 75, 199 | }, 200 | FailureHTMLURL: []string{ 201 | "https://github.com/owner/repo/actions/runs/10002/job/2", 202 | }, 203 | }, 204 | { 205 | Name: "Complete job", 206 | Number: 3, 207 | RunsCount: 3, 208 | Conclusions: map[string]int{ 209 | "success": 3, 210 | "failure": 0, 211 | "others": 0, 212 | }, 213 | Rate: Rate{ 214 | SuccesRate: 1, 215 | FailureRate: 0, 216 | OthersRate: 0, 217 | }, 218 | ExecutionDurationStats: ExecutionDurationStats{ 219 | Min: 0, 220 | Max: 0, 221 | Avg: 0, 222 | Std: 0, 223 | Med: 0, 224 | }, 225 | 226 | FailureHTMLURL: []string{}, 227 | }, 228 | }, 229 | }, 230 | }, 231 | }, 232 | } 233 | for _, tt := range tests { 234 | t.Run(tt.name, func(t *testing.T) { 235 | d, err := os.ReadFile("testdata/jobs/" + tt.args.file) 236 | if err != nil { 237 | t.Fatal(err) 238 | } 239 | var wjs []*github.WorkflowJob 240 | if err := json.Unmarshal(d, &wjs); err != nil { 241 | t.Fatal(err) 242 | } 243 | got := WorkflowJobsParse(wjs) 244 | assert.Equal(t, tt.want, got) 245 | 246 | }) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /internal/parser/runs.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/google/go-github/v60/github" 8 | ) 9 | 10 | const ( 11 | ConclusionSuccess = "success" 12 | ConclusionFailure = "failure" 13 | ConclusionOthers = "others" 14 | StatusCompleted = "completed" 15 | ) 16 | 17 | type WorkflowRunsStatsSummary struct { 18 | TotalRunsCount int `json:"total_runs_count"` 19 | Name string `json:"name"` 20 | Rate Rate `json:"rate"` 21 | ExecutionDurationStats ExecutionDurationStats `json:"execution_duration_stats"` 22 | Conclusions map[string]*WorkflowRunsConclusion `json:"conclusions"` 23 | } 24 | 25 | type Rate struct { 26 | SuccesRate float64 `json:"success_rate"` 27 | FailureRate float64 `json:"failure_rate"` 28 | OthersRate float64 `json:"others_rate"` 29 | } 30 | 31 | type WorkflowRunsConclusion struct { 32 | RunsCount int `json:"runs_count"` 33 | WorkflowRuns []*WorkflowRun `json:"workflow_runs"` 34 | } 35 | 36 | type WorkflowRun struct { 37 | ID int64 `json:"id,omitempty"` 38 | Status string `json:"status"` 39 | Conclusion string `json:"conclusion"` 40 | Actor string `json:"actor"` 41 | RunAttempt int `json:"run_attempt"` 42 | HTMLURL string `json:"html_url"` 43 | JobsURL string `json:"jobs_url"` 44 | LogsURL string `json:"logs_url"` 45 | RunStartedAt time.Time `json:"run_started_at"` 46 | UpdateAt time.Time `json:"update_at"` 47 | CreatedAt time.Time `json:"created_at"` 48 | Duration float64 `json:"duration"` 49 | } 50 | 51 | func WorkflowRunsParse(wrs []*github.WorkflowRun) *WorkflowRunsStatsSummary { 52 | wfrss := &WorkflowRunsStatsSummary{ 53 | TotalRunsCount: 0, 54 | Conclusions: map[string]*WorkflowRunsConclusion{ 55 | ConclusionSuccess: { 56 | RunsCount: 0, 57 | WorkflowRuns: []*WorkflowRun{}, 58 | }, 59 | ConclusionFailure: { 60 | RunsCount: 0, 61 | WorkflowRuns: []*WorkflowRun{}, 62 | }, 63 | ConclusionOthers: { 64 | RunsCount: 0, 65 | WorkflowRuns: []*WorkflowRun{}, 66 | }, 67 | }, 68 | } 69 | if len(wrs) == 0 { 70 | return wfrss 71 | } 72 | wfrss.Name = wrs[0].GetName() 73 | 74 | durations := make([]float64, 0, len(wrs)) 75 | for _, wr := range wrs { 76 | c := wr.GetConclusion() 77 | if c != ConclusionSuccess && c != ConclusionFailure { 78 | c = ConclusionOthers 79 | } 80 | 81 | wfrss.TotalRunsCount++ 82 | wfrss.Conclusions[c].RunsCount++ 83 | 84 | w := WorkflowRun{ 85 | ID: wr.GetID(), 86 | Status: wr.GetStatus(), 87 | Conclusion: wr.GetConclusion(), 88 | Actor: wr.GetActor().GetLogin(), 89 | RunAttempt: wr.GetRunAttempt(), 90 | HTMLURL: wr.GetHTMLURL() + "/attempts/" + strconv.Itoa(wr.GetRunAttempt()), 91 | JobsURL: wr.GetJobsURL(), 92 | LogsURL: wr.GetLogsURL(), 93 | RunStartedAt: wr.GetRunStartedAt().Time.UTC(), 94 | UpdateAt: wr.GetUpdatedAt().Time.UTC(), 95 | CreatedAt: wr.GetCreatedAt().Time.UTC(), 96 | } 97 | // TODO: This is not the correct way to calculate the duration. https://github.com/fchimpan/gh-workflow-stats/issues/11 98 | d := wr.GetUpdatedAt().Sub(wr.GetRunStartedAt().Time).Seconds() 99 | // Maximum duration of a workflow run is 35 days in Self-hosted runners. ref: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#usage-limits 100 | if d > 35*24*60*60 { 101 | d = 3024000 102 | } 103 | w.Duration = d 104 | if c == ConclusionSuccess && d > 0 && wr.GetStatus() == StatusCompleted { 105 | durations = append(durations, d) 106 | } 107 | wfrss.Conclusions[c].WorkflowRuns = append(wfrss.Conclusions[c].WorkflowRuns, &w) 108 | } 109 | 110 | wfrss.ExecutionDurationStats = calcStats(durations) 111 | wfrss.Rate.SuccesRate = float64(wfrss.Conclusions[ConclusionSuccess].RunsCount) / max(float64(wfrss.TotalRunsCount), 1) 112 | wfrss.Rate.FailureRate = float64(wfrss.Conclusions[ConclusionFailure].RunsCount) / max(float64(wfrss.TotalRunsCount), 1) 113 | wfrss.Rate.OthersRate = float64(1 - wfrss.Rate.SuccesRate - wfrss.Rate.FailureRate) 114 | 115 | return wfrss 116 | } 117 | -------------------------------------------------------------------------------- /internal/parser/runs_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-github/v60/github" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestWorkflowRunsParse(t *testing.T) { 14 | type args struct { 15 | file string 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | want *WorkflowRunsStatsSummary 21 | }{ 22 | { 23 | name: "empty", 24 | args: args{ 25 | file: "empty.json", 26 | }, 27 | want: &WorkflowRunsStatsSummary{ 28 | TotalRunsCount: 0, 29 | Conclusions: map[string]*WorkflowRunsConclusion{ 30 | ConclusionSuccess: { 31 | RunsCount: 0, 32 | WorkflowRuns: []*WorkflowRun{}, 33 | }, 34 | ConclusionFailure: { 35 | RunsCount: 0, 36 | WorkflowRuns: []*WorkflowRun{}, 37 | }, 38 | ConclusionOthers: { 39 | RunsCount: 0, 40 | WorkflowRuns: []*WorkflowRun{}, 41 | }, 42 | }, 43 | }, 44 | }, 45 | { 46 | name: "Success", 47 | args: args{ 48 | file: "success.json", 49 | }, 50 | want: &WorkflowRunsStatsSummary{ 51 | TotalRunsCount: 2, 52 | Name: "CI", 53 | Rate: Rate{ 54 | SuccesRate: 1, 55 | FailureRate: 0, 56 | OthersRate: 0, 57 | }, 58 | ExecutionDurationStats: ExecutionDurationStats{ 59 | Min: 20.0, 60 | Max: 40.0, 61 | Avg: 30.0, 62 | Std: 10, 63 | Med: 30.0, 64 | }, 65 | Conclusions: map[string]*WorkflowRunsConclusion{ 66 | ConclusionSuccess: { 67 | RunsCount: 2, 68 | WorkflowRuns: []*WorkflowRun{ 69 | { 70 | ID: 10000, 71 | Status: "completed", 72 | Conclusion: "success", 73 | Actor: "test-user", 74 | RunAttempt: 1, 75 | HTMLURL: "https://github.com/owner/repos/actions/runs/10000/attempts/1", 76 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 77 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/logs", 78 | RunStartedAt: timeParse("2024-01-01T00:00:00Z"), 79 | UpdateAt: timeParse("2024-01-01T00:00:20Z"), 80 | CreatedAt: timeParse("2024-01-01T00:00:00Z"), 81 | Duration: 20.0, 82 | }, 83 | { 84 | ID: 10001, 85 | Status: "completed", 86 | Conclusion: "success", 87 | Actor: "test-user2", 88 | RunAttempt: 1, 89 | HTMLURL: "https://github.com/owner/repos/actions/runs/10001/attempts/1", 90 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 91 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/logs", 92 | RunStartedAt: timeParse("2024-01-01T00:01:00Z"), 93 | UpdateAt: timeParse("2024-01-01T00:01:40Z"), 94 | CreatedAt: timeParse("2024-01-01T00:01:00Z"), 95 | Duration: 40.0, 96 | }, 97 | }, 98 | }, 99 | ConclusionFailure: { 100 | RunsCount: 0, 101 | WorkflowRuns: []*WorkflowRun{}, 102 | }, 103 | ConclusionOthers: { 104 | RunsCount: 0, 105 | WorkflowRuns: []*WorkflowRun{}, 106 | }, 107 | }, 108 | }, 109 | }, 110 | { 111 | name: "Failure and others", 112 | args: args{ 113 | file: "failure-others.json", 114 | }, 115 | want: &WorkflowRunsStatsSummary{ 116 | TotalRunsCount: 2, 117 | Name: "CI", 118 | Rate: Rate{ 119 | SuccesRate: 0, 120 | FailureRate: 0.5, 121 | OthersRate: 0.5, 122 | }, 123 | ExecutionDurationStats: ExecutionDurationStats{ 124 | Min: 0, 125 | Max: 0, 126 | Avg: 0, 127 | Std: 0, 128 | Med: 0, 129 | }, 130 | Conclusions: map[string]*WorkflowRunsConclusion{ 131 | ConclusionSuccess: { 132 | RunsCount: 0, 133 | WorkflowRuns: []*WorkflowRun{}, 134 | }, 135 | ConclusionFailure: { 136 | RunsCount: 1, 137 | WorkflowRuns: []*WorkflowRun{ 138 | { 139 | ID: 10000, 140 | Status: "completed", 141 | Conclusion: "failure", 142 | Actor: "test-user", 143 | RunAttempt: 1, 144 | HTMLURL: "https://github.com/owner/repos/actions/runs/10000/attempts/1", 145 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 146 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/logs", 147 | RunStartedAt: timeParse("2023-01-01T00:00:00Z"), 148 | UpdateAt: timeParse("2024-01-01T00:00:20Z"), 149 | CreatedAt: timeParse("2023-01-01T00:00:00Z"), 150 | Duration: 3024000, 151 | }, 152 | }, 153 | }, 154 | 155 | ConclusionOthers: { 156 | RunsCount: 1, 157 | WorkflowRuns: []*WorkflowRun{ 158 | { 159 | ID: 10001, 160 | Status: "completed", 161 | Conclusion: "other", 162 | Actor: "test-user", 163 | RunAttempt: 1, 164 | HTMLURL: "https://github.com/owner/repos/actions/runs/10001/attempts/1", 165 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 166 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/logs", 167 | RunStartedAt: timeParse("2024-01-01T00:00:00Z"), 168 | UpdateAt: timeParse("2024-01-01T00:00:20Z"), 169 | CreatedAt: timeParse("2024-01-01T00:00:00Z"), 170 | Duration: 20.0, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }, 177 | { 178 | name: "Multiple conclusions", 179 | args: args{ 180 | file: "multiple-conclusions.json", 181 | }, 182 | want: &WorkflowRunsStatsSummary{ 183 | TotalRunsCount: 3, 184 | Name: "CI", 185 | Rate: Rate{ 186 | SuccesRate: 0.3333333333333333, 187 | FailureRate: 0.3333333333333333, 188 | OthersRate: 0.3333333333333334, 189 | }, 190 | ExecutionDurationStats: ExecutionDurationStats{ 191 | Min: 20.0, 192 | Max: 20.0, 193 | Avg: 20.0, 194 | Std: 0.0, 195 | Med: 20.0, 196 | }, 197 | Conclusions: map[string]*WorkflowRunsConclusion{ 198 | ConclusionSuccess: { 199 | RunsCount: 1, 200 | WorkflowRuns: []*WorkflowRun{ 201 | { 202 | ID: 10000, 203 | Status: "completed", 204 | Conclusion: "success", 205 | Actor: "test-user", 206 | RunAttempt: 2, 207 | HTMLURL: "https://github.com/owner/repos/actions/runs/10000/attempts/2", 208 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 209 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10000/logs", 210 | RunStartedAt: timeParse("2024-01-01T00:00:00Z"), 211 | UpdateAt: timeParse("2024-01-01T00:00:20Z"), 212 | CreatedAt: timeParse("2024-01-01T00:00:00Z"), 213 | Duration: 20.0, 214 | }, 215 | }, 216 | }, 217 | ConclusionFailure: { 218 | RunsCount: 1, 219 | WorkflowRuns: []*WorkflowRun{ 220 | { 221 | ID: 10001, 222 | Status: "completed", 223 | Conclusion: "failure", 224 | Actor: "test-user", 225 | RunAttempt: 1, 226 | HTMLURL: "https://github.com/owner/repos/actions/runs/10001/attempts/1", 227 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 228 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10001/logs", 229 | RunStartedAt: timeParse("2024-01-01T00:00:00Z"), 230 | UpdateAt: timeParse("2024-01-01T00:00:40Z"), 231 | CreatedAt: timeParse("2024-01-01T00:00:00Z"), 232 | Duration: 40.0, 233 | }, 234 | }, 235 | }, 236 | ConclusionOthers: { 237 | RunsCount: 1, 238 | WorkflowRuns: []*WorkflowRun{ 239 | { 240 | ID: 10002, 241 | Status: "completed", 242 | Conclusion: "other", 243 | Actor: "test-user", 244 | RunAttempt: 1, 245 | HTMLURL: "https://github.com/owner/repos/actions/runs/10002/attempts/1", 246 | JobsURL: "https://api.github.com/repos/owner/repos/actions/runs/10002/jobs", 247 | LogsURL: "https://api.github.com/repos/owner/repos/actions/runs/10002/logs", 248 | RunStartedAt: timeParse("2024-01-01T00:00:00Z"), 249 | UpdateAt: timeParse("2024-01-01T00:00:30Z"), 250 | CreatedAt: timeParse("2024-01-01T00:00:00Z"), 251 | Duration: 30.0, 252 | }, 253 | }, 254 | }, 255 | }, 256 | }, 257 | }, 258 | } // Add closing brace here 259 | 260 | for _, tt := range tests { 261 | t.Run(tt.name, func(t *testing.T) { 262 | d, err := os.ReadFile("testdata/runs/" + tt.args.file) 263 | if err != nil { 264 | t.Fatal(err) 265 | } 266 | var wrs github.WorkflowRuns 267 | if err := json.Unmarshal(d, &wrs); err != nil { 268 | t.Fatal(err) 269 | } 270 | got := WorkflowRunsParse(wrs.WorkflowRuns) 271 | assert.Equal(t, tt.want.TotalRunsCount, got.TotalRunsCount) 272 | assert.Equal(t, tt.want.Name, got.Name) 273 | assert.Equal(t, tt.want.Rate, got.Rate) 274 | assert.Equal(t, tt.want.ExecutionDurationStats, got.ExecutionDurationStats) 275 | 276 | for _, c := range []string{ConclusionSuccess, ConclusionFailure, ConclusionOthers} { 277 | assert.Equal(t, tt.want.Conclusions[c].RunsCount, got.Conclusions[c].RunsCount) 278 | for i, wr := range tt.want.Conclusions[c].WorkflowRuns { 279 | assert.Equal(t, wr.ID, got.Conclusions[c].WorkflowRuns[i].ID) 280 | assert.Equal(t, wr.Status, got.Conclusions[c].WorkflowRuns[i].Status) 281 | assert.Equal(t, wr.Conclusion, got.Conclusions[c].WorkflowRuns[i].Conclusion) 282 | assert.Equal(t, wr.Actor, got.Conclusions[c].WorkflowRuns[i].Actor) 283 | assert.Equal(t, wr.RunAttempt, got.Conclusions[c].WorkflowRuns[i].RunAttempt) 284 | assert.Equal(t, wr.HTMLURL, got.Conclusions[c].WorkflowRuns[i].HTMLURL) 285 | assert.Equal(t, wr.JobsURL, got.Conclusions[c].WorkflowRuns[i].JobsURL) 286 | assert.Equal(t, wr.LogsURL, got.Conclusions[c].WorkflowRuns[i].LogsURL) 287 | assert.True(t, wr.RunStartedAt.Equal(got.Conclusions[c].WorkflowRuns[i].RunStartedAt)) 288 | assert.True(t, wr.UpdateAt.Equal(got.Conclusions[c].WorkflowRuns[i].UpdateAt)) 289 | assert.True(t, wr.CreatedAt.Equal(got.Conclusions[c].WorkflowRuns[i].CreatedAt)) 290 | assert.Equal(t, wr.Duration, got.Conclusions[c].WorkflowRuns[i].Duration) 291 | 292 | } 293 | } 294 | }) 295 | } 296 | } 297 | 298 | func timeParse(s string) time.Time { 299 | t, _ := time.ParseInLocation(time.RFC3339, s, time.UTC) 300 | return t.Round(0) 301 | } 302 | -------------------------------------------------------------------------------- /internal/parser/testdata/jobs/empty.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /internal/parser/testdata/jobs/multiple-jobs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "run_id": 10001, 5 | "workflow_name": "CI", 6 | "head_branch": "test", 7 | "run_url": "https://api.github.com/repos/owner/repo/actions/runs/10001", 8 | "run_attempt": 1, 9 | "node_id": "CR_kwDOK4OkS88AAAAFZNVyww", 10 | "head_sha": "d6af9d32f7e6ca18056f814296aac173d41db62b", 11 | "url": "https://api.github.com/repos/owner/repo/actions/jobs/1", 12 | "html_url": "https://github.com/owner/repo/actions/runs/10001/job/1", 13 | "status": "completed", 14 | "conclusion": "success", 15 | "created_at": "2024-01-01T00:00:00Z", 16 | "started_at": "2024-01-01T00:00:00Z", 17 | "completed_at": "2024-01-01T00:01:00Z", 18 | "name": "test", 19 | "steps": [ 20 | { 21 | "name": "Set up job", 22 | "status": "completed", 23 | "conclusion": "success", 24 | "number": 1, 25 | "started_at": "2024-01-01T00:00:00.000+00:00", 26 | "completed_at": "2024-01-01T00:00:10.000+00:00" 27 | }, 28 | { 29 | "name": "Run test", 30 | "status": "completed", 31 | "conclusion": "success", 32 | "number": 2, 33 | "started_at": "2024-01-01T00:00:10.000+00:00", 34 | "completed_at": "2024-01-01T00:01:00.000+00:00" 35 | }, 36 | { 37 | "name": "Complete job", 38 | "status": "completed", 39 | "conclusion": "success", 40 | "number": 3, 41 | "started_at": "2024-01-01T00:01:00.000+00:00", 42 | "completed_at": "2024-01-01T00:01:00.000+00:00" 43 | } 44 | ], 45 | "check_run_url": "https://api.github.com/repos/owner/repo/check-runs/1", 46 | "labels": ["ubuntu-latest"], 47 | "runner_id": 4, 48 | "runner_name": "GitHub Actions 4", 49 | "runner_group_id": 2, 50 | "runner_group_name": "GitHub Actions" 51 | }, 52 | { 53 | "id": 2, 54 | "run_id": 10002, 55 | "workflow_name": "CI", 56 | "head_branch": "test", 57 | "run_url": "https://api.github.com/repos/owner/repo/actions/runs/10002", 58 | "run_attempt": 1, 59 | "node_id": "CR_kwDOK4OkS88AAAAFZNVyww", 60 | "head_sha": "d6af9d32f7e6ca18056f814296aac173d41db62b", 61 | "url": "https://api.github.com/repos/owner/repo/actions/jobs/2", 62 | "html_url": "https://github.com/owner/repo/actions/runs/10002/job/2", 63 | "status": "completed", 64 | "conclusion": "failure", 65 | "created_at": "2024-01-01T00:00:00Z", 66 | "started_at": "2024-01-01T00:00:00Z", 67 | "completed_at": "2024-01-01T00:02:00Z", 68 | "name": "test", 69 | "steps": [ 70 | { 71 | "name": "Set up job", 72 | "status": "completed", 73 | "conclusion": "success", 74 | "number": 1, 75 | "started_at": "2024-01-01T00:00:00.000+00:00", 76 | "completed_at": "2024-01-01T00:00:20.000+00:00" 77 | }, 78 | { 79 | "name": "Run test", 80 | "status": "completed", 81 | "conclusion": "failure", 82 | "number": 2, 83 | "started_at": "2024-01-01T00:00:20.000+00:00", 84 | "completed_at": "2024-01-01T00:02:00.000+00:00" 85 | }, 86 | { 87 | "name": "Complete job", 88 | "status": "completed", 89 | "conclusion": "success", 90 | "number": 3, 91 | "started_at": "2024-01-01T00:02:00.000+00:00", 92 | "completed_at": "2024-01-01T00:02:00.000+00:00" 93 | } 94 | ], 95 | "check_run_url": "https://api.github.com/repos/owner/repo/check-runs/2", 96 | "labels": ["ubuntu-latest"], 97 | "runner_id": 4, 98 | "runner_name": "GitHub Actions 4", 99 | "runner_group_id": 2, 100 | "runner_group_name": "GitHub Actions" 101 | }, 102 | { 103 | "id": 3, 104 | "run_id": 10003, 105 | "workflow_name": "CI", 106 | "head_branch": "test", 107 | "run_url": "https://api.github.com/repos/owner/repo/actions/runs/10003", 108 | "run_attempt": 1, 109 | "node_id": "CR_kwDOK4OkS88AAAAFZNVyww", 110 | "head_sha": "d6af9d32f7e6ca18056f814296aac173d41db62b", 111 | "url": "https://api.github.com/repos/owner/repo/actions/jobs/3", 112 | "html_url": "https://github.com/owner/repo/actions/runs/10003/job/3", 113 | "status": "completed", 114 | "conclusion": "other", 115 | "created_at": "2024-01-01T00:00:00Z", 116 | "started_at": "2024-01-01T00:00:00Z", 117 | "completed_at": "2024-01-01T00:02:00Z", 118 | "name": "test", 119 | "steps": [ 120 | { 121 | "name": "Set up job", 122 | "status": "completed", 123 | "conclusion": "success", 124 | "number": 1, 125 | "started_at": "2024-01-01T00:00:00.000+00:00", 126 | "completed_at": "2024-01-01T00:00:20.000+00:00" 127 | }, 128 | { 129 | "name": "Run test", 130 | "status": "completed", 131 | "conclusion": "cancelled", 132 | "number": 2, 133 | "started_at": "2024-01-01T00:00:20.000+00:00", 134 | "completed_at": "2024-01-01T00:02:00.000+00:00" 135 | }, 136 | { 137 | "name": "Complete job", 138 | "status": "completed", 139 | "conclusion": "success", 140 | "number": 3, 141 | "started_at": "2024-01-01T00:02:00.000+00:00", 142 | "completed_at": "2024-01-01T00:02:00.000+00:00" 143 | } 144 | ], 145 | "check_run_url": "https://api.github.com/repos/owner/repo/check-runs/3", 146 | "labels": ["ubuntu-latest"], 147 | "runner_id": 4, 148 | "runner_name": "GitHub Actions 4", 149 | "runner_group_id": 2, 150 | "runner_group_name": "GitHub Actions" 151 | } 152 | ] 153 | -------------------------------------------------------------------------------- /internal/parser/testdata/jobs/multiple-success.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "run_id": 10001, 5 | "workflow_name": "CI", 6 | "head_branch": "test", 7 | "run_url": "https://api.github.com/repos/owner/repo/actions/runs/10001", 8 | "run_attempt": 1, 9 | "node_id": "CR_kwDOK4OkS88AAAAFZNVyww", 10 | "head_sha": "d6af9d32f7e6ca18056f814296aac173d41db62b", 11 | "url": "https://api.github.com/repos/owner/repo/actions/jobs/1", 12 | "html_url": "https://github.com/owner/repo/actions/runs/10001/job/1", 13 | "status": "completed", 14 | "conclusion": "success", 15 | "created_at": "2024-01-01T00:00:00Z", 16 | "started_at": "2024-01-01T00:00:00Z", 17 | "completed_at": "2024-01-01T00:01:00Z", 18 | "name": "test", 19 | "steps": [ 20 | { 21 | "name": "Set up job", 22 | "status": "completed", 23 | "conclusion": "success", 24 | "number": 1, 25 | "started_at": "2024-01-01T00:00:00.000+00:00", 26 | "completed_at": "2024-01-01T00:00:10.000+00:00" 27 | }, 28 | { 29 | "name": "Run test", 30 | "status": "completed", 31 | "conclusion": "success", 32 | "number": 2, 33 | "started_at": "2024-01-01T00:00:10.000+00:00", 34 | "completed_at": "2024-01-01T00:01:00.000+00:00" 35 | }, 36 | { 37 | "name": "Complete job", 38 | "status": "completed", 39 | "conclusion": "success", 40 | "number": 3, 41 | "started_at": "2024-01-01T00:01:00.000+00:00", 42 | "completed_at": "2024-01-01T00:01:00.000+00:00" 43 | } 44 | ], 45 | "check_run_url": "https://api.github.com/repos/owner/repo/check-runs/1", 46 | "labels": ["ubuntu-latest"], 47 | "runner_id": 4, 48 | "runner_name": "GitHub Actions 4", 49 | "runner_group_id": 2, 50 | "runner_group_name": "GitHub Actions" 51 | }, 52 | { 53 | "id": 2, 54 | "run_id": 10002, 55 | "workflow_name": "CI", 56 | "head_branch": "test", 57 | "run_url": "https://api.github.com/repos/owner/repo/actions/runs/10002", 58 | "run_attempt": 1, 59 | "node_id": "CR_kwDOK4OkS88AAAAFZNVyww", 60 | "head_sha": "d6af9d32f7e6ca18056f814296aac173d41db62b", 61 | "url": "https://api.github.com/repos/owner/repo/actions/jobs/2", 62 | "html_url": "https://github.com/owner/repo/actions/runs/10002/job/2", 63 | "status": "completed", 64 | "conclusion": "success", 65 | "created_at": "2024-01-01T00:00:00Z", 66 | "started_at": "2024-01-01T00:00:00Z", 67 | "completed_at": "2024-01-01T00:02:00Z", 68 | "name": "test", 69 | "steps": [ 70 | { 71 | "name": "Set up job", 72 | "status": "completed", 73 | "conclusion": "success", 74 | "number": 1, 75 | "started_at": "2024-01-01T00:00:00.000+00:00", 76 | "completed_at": "2024-01-01T00:00:20.000+00:00" 77 | }, 78 | { 79 | "name": "Run test", 80 | "status": "completed", 81 | "conclusion": "success", 82 | "number": 2, 83 | "started_at": "2024-01-01T00:00:20.000+00:00", 84 | "completed_at": "2024-01-01T00:02:00.000+00:00" 85 | }, 86 | { 87 | "name": "Complete job", 88 | "status": "completed", 89 | "conclusion": "success", 90 | "number": 3, 91 | "started_at": "2024-01-01T00:02:00.000+00:00", 92 | "completed_at": "2024-01-01T00:02:00.000+00:00" 93 | } 94 | ], 95 | "check_run_url": "https://api.github.com/repos/owner/repo/check-runs/2", 96 | "labels": ["ubuntu-latest"], 97 | "runner_id": 4, 98 | "runner_name": "GitHub Actions 4", 99 | "runner_group_id": 2, 100 | "runner_group_name": "GitHub Actions" 101 | } 102 | ] 103 | -------------------------------------------------------------------------------- /internal/parser/testdata/runs/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 0, 3 | "workflow_runs": [] 4 | } 5 | -------------------------------------------------------------------------------- /internal/parser/testdata/runs/failure-others.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 2, 3 | "workflow_runs": [ 4 | { 5 | "id": 10000, 6 | "name": "CI", 7 | "status": "completed", 8 | "conclusion": "failure", 9 | "html_url": "https://github.com/owner/repos/actions/runs/10000", 10 | "created_at": "2023-01-01T00:00:00Z", 11 | "updated_at": "2024-01-01T00:00:20Z", 12 | "run_started_at": "2023-01-01T00:00:00Z", 13 | "run_attempt": 1, 14 | "actor": { 15 | "login": "test-user" 16 | }, 17 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 18 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/logs" 19 | }, 20 | { 21 | "id": 10001, 22 | "name": "CI", 23 | "status": "completed", 24 | "conclusion": "other", 25 | "html_url": "https://github.com/owner/repos/actions/runs/10001", 26 | "created_at": "2024-01-01T00:00:00Z", 27 | "updated_at": "2024-01-01T00:00:20Z", 28 | "run_started_at": "2024-01-01T00:00:00Z", 29 | "run_attempt": 1, 30 | "actor": { 31 | "login": "test-user" 32 | }, 33 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 34 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/logs" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /internal/parser/testdata/runs/multiple-conclusions.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 3, 3 | "workflow_runs": [ 4 | { 5 | "id": 10000, 6 | "name": "CI", 7 | "status": "completed", 8 | "conclusion": "success", 9 | "html_url": "https://github.com/owner/repos/actions/runs/10000", 10 | "created_at": "2024-01-01T00:00:00Z", 11 | "updated_at": "2024-01-01T00:00:20Z", 12 | "run_started_at": "2024-01-01T00:00:00Z", 13 | "run_attempt": 2, 14 | "actor": { 15 | "login": "test-user" 16 | }, 17 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 18 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/logs" 19 | }, 20 | { 21 | "id": 10001, 22 | "name": "CI", 23 | "status": "completed", 24 | "conclusion": "failure", 25 | "html_url": "https://github.com/owner/repos/actions/runs/10001", 26 | "created_at": "2024-01-01T00:00:00Z", 27 | "updated_at": "2024-01-01T00:00:40Z", 28 | "run_started_at": "2024-01-01T00:00:00Z", 29 | "run_attempt": 1, 30 | "actor": { 31 | "login": "test-user" 32 | }, 33 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 34 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/logs" 35 | }, 36 | { 37 | "id": 10002, 38 | "name": "CI", 39 | "status": "completed", 40 | "conclusion": "other", 41 | "html_url": "https://github.com/owner/repos/actions/runs/10002", 42 | "created_at": "2024-01-01T00:00:00Z", 43 | "updated_at": "2024-01-01T00:00:30Z", 44 | "run_started_at": "2024-01-01T00:00:00Z", 45 | "run_attempt": 1, 46 | "actor": { 47 | "login": "test-user" 48 | }, 49 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10002/jobs", 50 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10002/logs" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /internal/parser/testdata/runs/success.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 2, 3 | "workflow_runs": [ 4 | { 5 | "id": 10000, 6 | "name": "CI", 7 | "status": "completed", 8 | "conclusion": "success", 9 | "html_url": "https://github.com/owner/repos/actions/runs/10000", 10 | "created_at": "2024-01-01T00:00:00Z", 11 | "updated_at": "2024-01-01T00:00:20Z", 12 | "run_started_at": "2024-01-01T00:00:00Z", 13 | "run_attempt": 1, 14 | "actor": { 15 | "login": "test-user" 16 | }, 17 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/jobs", 18 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10000/logs" 19 | }, 20 | { 21 | "id": 10001, 22 | "name": "CI", 23 | "status": "completed", 24 | "conclusion": "success", 25 | "html_url": "https://github.com/owner/repos/actions/runs/10001", 26 | "created_at": "2024-01-01T00:01:00Z", 27 | "updated_at": "2024-01-01T00:01:40Z", 28 | "run_started_at": "2024-01-01T00:01:00Z", 29 | "run_attempt": 1, 30 | "actor": { 31 | "login": "test-user2" 32 | }, 33 | "jobs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/jobs", 34 | "logs_url": "https://api.github.com/repos/owner/repos/actions/runs/10001/logs" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /internal/printer/jobs.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sort" 7 | 8 | "github.com/fatih/color" 9 | "github.com/fchimpan/gh-workflow-stats/internal/parser" 10 | ) 11 | 12 | func FailureJobs(w io.Writer, jobs []*parser.WorkflowJobsStatsSummary, n int) { 13 | sort.Slice(jobs, func(i, j int) bool { 14 | return jobs[i].Conclusions[parser.ConclusionFailure] > jobs[j].Conclusions[parser.ConclusionFailure] 15 | }) 16 | jobsNum := min(len(jobs), n) 17 | fmt.Fprintf(w, "\n%s Top %d jobs with the highest failure counts (failure jobs / total runs)\n", "\U0001F4C8", jobsNum) 18 | 19 | cnt := 0 20 | red := color.New(color.FgRed).SprintFunc() 21 | purple := color.New(color.FgHiMagenta).SprintFunc() 22 | cyan := color.New(color.FgCyan).SprintFunc() 23 | 24 | for _, job := range jobs { 25 | maxFailuresStepCount := 0 26 | maxTotalStepCount := 0 27 | maxFailuresStepName := "" 28 | for _, step := range job.StepSummary { 29 | if step.Conclusions[parser.ConclusionFailure] > maxFailuresStepCount { 30 | maxFailuresStepCount = step.Conclusions[parser.ConclusionFailure] 31 | maxTotalStepCount = step.RunsCount 32 | maxFailuresStepName = step.Name 33 | } 34 | } 35 | if maxFailuresStepCount == 0 { 36 | maxTotalStepCount = job.TotalRunsCount 37 | maxFailuresStepName = "Failed step not found" 38 | } 39 | 40 | fmt.Fprintf(w, " %s: %d/%d\n", cyan(job.Name), job.Conclusions[parser.ConclusionFailure], job.TotalRunsCount) 41 | fmt.Fprintf(w, " └──%s: %s\n\n", purple(maxFailuresStepName), red(fmt.Sprintf("%d/%d", maxFailuresStepCount, maxTotalStepCount))) 42 | 43 | cnt++ 44 | if cnt >= jobsNum { 45 | break 46 | } 47 | } 48 | } 49 | 50 | func LongestDurationJobs(w io.Writer, jobs []*parser.WorkflowJobsStatsSummary, n int) { 51 | sort.Slice(jobs, func(i, j int) bool { 52 | return jobs[i].ExecutionDurationStats.Avg > jobs[j].ExecutionDurationStats.Avg 53 | }) 54 | jobsNum := min(len(jobs), n) 55 | fmt.Fprintf(w, "\n%s Top %d jobs with the longest execution average duration\n", "\U0001F4CA", jobsNum) 56 | 57 | red := color.New(color.FgRed).SprintFunc() 58 | cyan := color.New(color.FgCyan).SprintFunc() 59 | 60 | for i := 0; i < jobsNum; i++ { 61 | job := jobs[i] 62 | fmt.Fprintf(w, " %s: %s\n", cyan(job.Name), red(fmt.Sprintf("%.2fs", job.ExecutionDurationStats.Avg))) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/printer/jobs_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/fchimpan/gh-workflow-stats/internal/parser" 8 | ) 9 | 10 | func TestFailureJobs(t *testing.T) { 11 | type args struct { 12 | jobs []*parser.WorkflowJobsStatsSummary 13 | n int 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | wantW string 19 | }{ 20 | { 21 | name: "Empty", 22 | args: args{ 23 | jobs: []*parser.WorkflowJobsStatsSummary{}, 24 | n: 3, 25 | }, 26 | wantW: "\n📈 Top 0 jobs with the highest failure counts (failure jobs / total runs)\n", 27 | }, 28 | { 29 | name: "Positive", 30 | args: args{ 31 | jobs: []*parser.WorkflowJobsStatsSummary{ 32 | { 33 | Name: "job1", 34 | Conclusions: map[string]int{"failure": 5}, 35 | TotalRunsCount: 10, 36 | StepSummary: []*parser.StepSummary{ 37 | { 38 | Name: "job1-step1", 39 | Number: 1, 40 | RunsCount: 10, 41 | Conclusions: map[string]int{"failure": 5}, 42 | }, 43 | { 44 | Name: "job1-step2", 45 | Number: 2, 46 | RunsCount: 10, 47 | Conclusions: map[string]int{"failure": 3}, 48 | }, 49 | }, 50 | }, 51 | { 52 | Name: "job2", 53 | Conclusions: map[string]int{"failure": 2}, 54 | TotalRunsCount: 10, 55 | StepSummary: []*parser.StepSummary{ 56 | { 57 | Name: "job2-step1", 58 | Number: 1, 59 | RunsCount: 10, 60 | Conclusions: map[string]int{"failure": 2}, 61 | }, 62 | { 63 | Name: "job2-step2", 64 | Number: 2, 65 | RunsCount: 10, 66 | Conclusions: map[string]int{"failure": 1}, 67 | }, 68 | }, 69 | }, 70 | }, 71 | n: 5, 72 | }, 73 | wantW: "\n📈 Top 2 jobs with the highest failure counts (failure jobs / total runs)\n job1: 5/10\n └──job1-step1: 5/10\n\n job2: 2/10\n └──job2-step1: 2/10\n\n", 74 | }, 75 | { 76 | name: "Multiple Jobs with the Same Number of Failures", 77 | args: args{ 78 | jobs: []*parser.WorkflowJobsStatsSummary{ 79 | { 80 | Name: "job2", 81 | Conclusions: map[string]int{"failure": 5}, 82 | TotalRunsCount: 10, 83 | StepSummary: []*parser.StepSummary{ 84 | { 85 | Name: "job2-step1", 86 | Number: 1, 87 | RunsCount: 10, 88 | Conclusions: map[string]int{"failure": 5}, 89 | }, 90 | { 91 | Name: "job2-step2", 92 | Number: 2, 93 | RunsCount: 10, 94 | Conclusions: map[string]int{"failure": 5}, 95 | }, 96 | }, 97 | }, 98 | { 99 | Name: "job1", 100 | Conclusions: map[string]int{"failure": 5}, 101 | TotalRunsCount: 10, 102 | StepSummary: []*parser.StepSummary{ 103 | { 104 | Name: "job1-step1", 105 | Number: 1, 106 | RunsCount: 10, 107 | Conclusions: map[string]int{"failure": 5}, 108 | }, 109 | { 110 | Name: "job1-step2", 111 | Number: 2, 112 | RunsCount: 10, 113 | Conclusions: map[string]int{"failure": 5}, 114 | }, 115 | }, 116 | }, 117 | { 118 | Name: "job3", 119 | Conclusions: map[string]int{"failure": 7}, 120 | TotalRunsCount: 10, 121 | StepSummary: []*parser.StepSummary{ 122 | { 123 | Name: "job3-step1", 124 | Number: 1, 125 | RunsCount: 10, 126 | Conclusions: map[string]int{"failure": 3}, 127 | }, 128 | { 129 | Name: "job3-step2", 130 | Number: 2, 131 | RunsCount: 10, 132 | Conclusions: map[string]int{"failure": 4}, 133 | }, 134 | }, 135 | }, 136 | }, 137 | n: 5, 138 | }, 139 | wantW: "\n📈 Top 3 jobs with the highest failure counts (failure jobs / total runs)\n job3: 7/10\n └──job3-step2: 4/10\n\n job2: 5/10\n └──job2-step1: 5/10\n\n job1: 5/10\n └──job1-step1: 5/10\n\n", 140 | }, 141 | { 142 | name: "Failure step not found", 143 | args: args{ 144 | jobs: []*parser.WorkflowJobsStatsSummary{ 145 | { 146 | Name: "job1", 147 | Conclusions: map[string]int{"failure": 0}, 148 | TotalRunsCount: 10, 149 | StepSummary: []*parser.StepSummary{ 150 | { 151 | Name: "job1-step1", 152 | Number: 1, 153 | RunsCount: 10, 154 | Conclusions: map[string]int{"failure": 0}, 155 | }, 156 | { 157 | Name: "job1-step2", 158 | Number: 2, 159 | RunsCount: 10, 160 | Conclusions: map[string]int{"failure": 0}, 161 | }, 162 | }, 163 | }, 164 | }, 165 | n: 5, 166 | }, 167 | wantW: "\n📈 Top 1 jobs with the highest failure counts (failure jobs / total runs)\n job1: 0/10\n └──Failed step not found: 0/10\n\n", 168 | }, 169 | } 170 | for _, tt := range tests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | w := &bytes.Buffer{} 173 | FailureJobs(w, tt.args.jobs, tt.args.n) 174 | if gotW := w.String(); gotW != tt.wantW { 175 | t.Errorf("FailureJobs() = %v, want %v", gotW, tt.wantW) 176 | } 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /internal/printer/runs.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/fatih/color" 8 | "github.com/fchimpan/gh-workflow-stats/internal/parser" 9 | ) 10 | 11 | const ( 12 | totalRunsFormat = "%s Total runs: %d\n" 13 | conclusionFormat = " %s: %d (%.1f%%)\n" 14 | executionTimeFormat = "\n%s Workflow run execution time stats\n" 15 | executionFormat = " %s: %.1fs\n" 16 | ) 17 | 18 | func Runs(w io.Writer, wrs *parser.WorkflowRunsStatsSummary) { 19 | var sc, fc, oc int 20 | var sr, fr, or float64 21 | if _, ok := wrs.Conclusions[parser.ConclusionSuccess]; ok { 22 | sc = wrs.Conclusions[parser.ConclusionSuccess].RunsCount 23 | sr = wrs.Rate.SuccesRate * 100 24 | } 25 | if _, ok := wrs.Conclusions[parser.ConclusionFailure]; ok { 26 | fc = wrs.Conclusions[parser.ConclusionFailure].RunsCount 27 | fr = wrs.Rate.FailureRate * 100 28 | } 29 | if _, ok := wrs.Conclusions[parser.ConclusionOthers]; ok { 30 | oc = wrs.Conclusions[parser.ConclusionOthers].RunsCount 31 | or = wrs.Rate.OthersRate * 100 32 | } 33 | 34 | green := color.New(color.FgGreen).SprintFunc() 35 | red := color.New(color.FgRed).SprintFunc() 36 | yellow := color.New(color.FgYellow).SprintFunc() 37 | 38 | fmt.Fprintf(w, totalRunsFormat, "\U0001F3C3", wrs.TotalRunsCount) 39 | 40 | fmt.Fprintf(w, conclusionFormat, green("\u2714 Success"), sc, sr) 41 | fmt.Fprintf(w, conclusionFormat, red("\u2716 Failure"), fc, fr) 42 | fmt.Fprintf(w, conclusionFormat, yellow("\U0001F914 Others"), oc, or) 43 | 44 | fmt.Fprintf(w, executionTimeFormat, "\u23F0") 45 | fmt.Fprintf(w, executionFormat, "Min", wrs.ExecutionDurationStats.Min) 46 | fmt.Fprintf(w, executionFormat, "Max", wrs.ExecutionDurationStats.Max) 47 | fmt.Fprintf(w, executionFormat, "Avg", wrs.ExecutionDurationStats.Avg) 48 | fmt.Fprintf(w, executionFormat, "Med", wrs.ExecutionDurationStats.Med) 49 | fmt.Fprintf(w, executionFormat, "Std", wrs.ExecutionDurationStats.Std) 50 | } 51 | -------------------------------------------------------------------------------- /internal/printer/runs_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/fchimpan/gh-workflow-stats/internal/parser" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRuns(t *testing.T) { 12 | type args struct { 13 | wrs *parser.WorkflowRunsStatsSummary 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | wantW string 19 | }{ 20 | { 21 | name: "Empty", 22 | args: args{ 23 | wrs: &parser.WorkflowRunsStatsSummary{ 24 | TotalRunsCount: 0, 25 | Rate: parser.Rate{}, 26 | Conclusions: map[string]*parser.WorkflowRunsConclusion{ 27 | parser.ConclusionSuccess: { 28 | RunsCount: 0, 29 | WorkflowRuns: []*parser.WorkflowRun{}, 30 | }, 31 | parser.ConclusionFailure: { 32 | RunsCount: 0, 33 | WorkflowRuns: []*parser.WorkflowRun{}, 34 | }, 35 | parser.ConclusionOthers: { 36 | RunsCount: 0, 37 | WorkflowRuns: []*parser.WorkflowRun{}, 38 | }, 39 | }, 40 | }, 41 | }, 42 | wantW: "🏃 Total runs: 0\n ✔ Success: 0 (0.0%)\n ✖ Failure: 0 (0.0%)\n 🤔 Others: 0 (0.0%)\n\n⏰ Workflow run execution time stats\n Min: 0.0s\n Max: 0.0s\n Avg: 0.0s\n Med: 0.0s\n Std: 0.0s\n", 43 | }, 44 | { 45 | name: "Success", 46 | args: args{ 47 | wrs: &parser.WorkflowRunsStatsSummary{ 48 | TotalRunsCount: 2, 49 | Name: "CI", 50 | Rate: parser.Rate{ 51 | SuccesRate: 1, 52 | FailureRate: 0.00001, 53 | OthersRate: 0, 54 | }, 55 | ExecutionDurationStats: parser.ExecutionDurationStats{ 56 | Min: 20.0, 57 | Max: 40.0, 58 | Avg: 30.0, 59 | Std: 10, 60 | Med: 30.0001, 61 | }, 62 | Conclusions: map[string]*parser.WorkflowRunsConclusion{ 63 | parser.ConclusionSuccess: { 64 | RunsCount: 2, 65 | WorkflowRuns: []*parser.WorkflowRun{ 66 | { 67 | ID: 1, 68 | Status: "completed", 69 | }, 70 | { 71 | ID: 2, 72 | Status: "completed", 73 | }, 74 | }, 75 | }, 76 | parser.ConclusionFailure: { 77 | RunsCount: 0, 78 | WorkflowRuns: []*parser.WorkflowRun{}, 79 | }, 80 | parser.ConclusionOthers: { 81 | RunsCount: 0, 82 | WorkflowRuns: []*parser.WorkflowRun{}, 83 | }, 84 | }, 85 | }, 86 | }, 87 | wantW: "🏃 Total runs: 2\n ✔ Success: 2 (100.0%)\n ✖ Failure: 0 (0.0%)\n 🤔 Others: 0 (0.0%)\n\n⏰ Workflow run execution time stats\n Min: 20.0s\n Max: 40.0s\n Avg: 30.0s\n Med: 30.0s\n Std: 10.0s\n", 88 | }, 89 | { 90 | name: "Mixed", 91 | args: args{ 92 | wrs: &parser.WorkflowRunsStatsSummary{ 93 | TotalRunsCount: 3, 94 | Name: "CI", 95 | Rate: parser.Rate{ 96 | SuccesRate: 0.6666666666666666, 97 | FailureRate: 0.3333333333333333, 98 | OthersRate: 0, 99 | }, 100 | ExecutionDurationStats: parser.ExecutionDurationStats{ 101 | Min: 20.0, 102 | Max: 40.0, 103 | Avg: 30.0, 104 | Std: 10, 105 | Med: 30.0001, 106 | }, 107 | Conclusions: map[string]*parser.WorkflowRunsConclusion{ 108 | parser.ConclusionSuccess: { 109 | RunsCount: 2, 110 | WorkflowRuns: []*parser.WorkflowRun{ 111 | { 112 | ID: 1, 113 | Status: "completed", 114 | }, 115 | { 116 | ID: 2, 117 | Status: "completed", 118 | }, 119 | }, 120 | }, 121 | parser.ConclusionFailure: { 122 | RunsCount: 1, 123 | WorkflowRuns: []*parser.WorkflowRun{ 124 | { 125 | ID: 3, 126 | Status: "completed", 127 | }, 128 | }, 129 | }, 130 | parser.ConclusionOthers: { 131 | RunsCount: 0, 132 | WorkflowRuns: []*parser.WorkflowRun{}, 133 | }, 134 | }, 135 | }, 136 | }, 137 | wantW: "🏃 Total runs: 3\n ✔ Success: 2 (66.7%)\n ✖ Failure: 1 (33.3%)\n 🤔 Others: 0 (0.0%)\n\n⏰ Workflow run execution time stats\n Min: 20.0s\n Max: 40.0s\n Avg: 30.0s\n Med: 30.0s\n Std: 10.0s\n", 138 | }, 139 | } 140 | 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | w := &bytes.Buffer{} 144 | Runs(w, tt.args.wrs) 145 | assert.Equal(t, tt.wantW, w.String()) 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /internal/printer/spinner.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/briandowns/spinner" 7 | ) 8 | 9 | type Spinner spinner.Spinner 10 | 11 | type SpinnerOptions struct { 12 | Text string 13 | CharSetsIndex int 14 | Color string 15 | } 16 | 17 | func NewSpinner(opt SpinnerOptions) (*Spinner, error) { 18 | s := spinner.New(spinner.CharSets[opt.CharSetsIndex], 100*time.Millisecond) 19 | s.Suffix = opt.Text 20 | if err := s.Color(opt.Color); err != nil { 21 | return nil, err 22 | } 23 | return (*Spinner)(s), nil 24 | } 25 | 26 | func (s *Spinner) Start() { 27 | (*spinner.Spinner)(s).Start() 28 | } 29 | 30 | func (s *Spinner) Stop() { 31 | (*spinner.Spinner)(s).Stop() 32 | } 33 | 34 | func (s *Spinner) Update(opt SpinnerOptions) { 35 | (*spinner.Spinner)(s).Suffix = opt.Text 36 | } 37 | -------------------------------------------------------------------------------- /internal/printer/warning.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | func RateLimitWarning(w io.Writer) { 9 | fmt.Fprintf(w, "\U000026A0 You have reached the rate limit for the GitHub API. These results may not be accurate.\n\n") 10 | } 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/fchimpan/gh-workflow-stats/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:weekly", 6 | ":semanticCommitTypeAll(chore)" 7 | ], 8 | "automerge": true, 9 | "labels": [ 10 | "renovate" 11 | ], 12 | "postUpdateOptions": [ 13 | "gomodTidy" 14 | ], 15 | "packageRules": [ 16 | { 17 | "groupName": "go-libraries", 18 | "matchManagers": ["gomod"] 19 | }, 20 | { 21 | "matchPackagePatterns": [ 22 | "^github.com/google/go-github/v" 23 | ], 24 | "enabled": false 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /sample/json-output.json: -------------------------------------------------------------------------------- 1 | { 2 | "workflow_runs_stats_summary": { 3 | "total_runs_count": 100, 4 | "name": "Tests", 5 | "rate": { 6 | "success_rate": 0.79, 7 | "failure_rate": 0.14, 8 | "others_rate": 0.06999999999999995 9 | }, 10 | "execution_duration_stats": { 11 | "min": 365, 12 | "max": 1080, 13 | "avg": 505.54430379746833, 14 | "med": 464, 15 | "std": 120.8452088122228 16 | }, 17 | "conclusions": { 18 | "failure": { 19 | "runs_count": 14, 20 | "workflow_runs": [ 21 | { 22 | "id": 8178615249, 23 | "status": "completed", 24 | "conclusion": "failure", 25 | "actor": "xxx", 26 | "run_attempt": 1, 27 | "html_url": "https://github.com/cli/cli/actions/runs/8178615249", 28 | "run_started_at": "2024-03-06T20:53:43Z", 29 | "duration": 0 30 | }, 31 | { 32 | "id": 8178494723, 33 | "status": "completed", 34 | "conclusion": "failure", 35 | "actor": "xxx", 36 | "run_attempt": 1, 37 | "html_url": "https://github.com/cli/cli/actions/runs/8178494723", 38 | "run_started_at": "2024-03-06T20:42:07Z", 39 | "duration": 0 40 | }, 41 | { 42 | "id": 8178432563, 43 | "status": "completed", 44 | "conclusion": "failure", 45 | "actor": "xxx", 46 | "run_attempt": 1, 47 | "html_url": "https://github.com/cli/cli/actions/runs/8178432563", 48 | "run_started_at": "2024-03-06T20:36:01Z", 49 | "duration": 0 50 | }, 51 | { 52 | "id": 8178314603, 53 | "status": "completed", 54 | "conclusion": "failure", 55 | "actor": "xxx", 56 | "run_attempt": 1, 57 | "html_url": "https://github.com/cli/cli/actions/runs/8178314603", 58 | "run_started_at": "2024-03-06T20:26:14Z", 59 | "duration": 0 60 | }, 61 | { 62 | "id": 8178250009, 63 | "status": "completed", 64 | "conclusion": "failure", 65 | "actor": "xxx", 66 | "run_attempt": 1, 67 | "html_url": "https://github.com/cli/cli/actions/runs/8178250009", 68 | "run_started_at": "2024-03-06T20:20:44Z", 69 | "duration": 0 70 | }, 71 | { 72 | "id": 8178247079, 73 | "status": "completed", 74 | "conclusion": "failure", 75 | "actor": "xxx", 76 | "run_attempt": 1, 77 | "html_url": "https://github.com/cli/cli/actions/runs/8178247079", 78 | "run_started_at": "2024-03-06T20:20:31Z", 79 | "duration": 0 80 | }, 81 | { 82 | "id": 8174112382, 83 | "status": "completed", 84 | "conclusion": "failure", 85 | "actor": "xxx", 86 | "run_attempt": 1, 87 | "html_url": "https://github.com/cli/cli/actions/runs/8174112382", 88 | "run_started_at": "2024-03-06T14:57:34Z", 89 | "duration": 0 90 | }, 91 | { 92 | "id": 8117020991, 93 | "status": "completed", 94 | "conclusion": "failure", 95 | "actor": "xxx", 96 | "run_attempt": 1, 97 | "html_url": "https://github.com/cli/cli/actions/runs/8117020991", 98 | "run_started_at": "2024-03-01T20:47:15Z", 99 | "duration": 0 100 | }, 101 | { 102 | "id": 8116997610, 103 | "status": "completed", 104 | "conclusion": "failure", 105 | "actor": "xxx", 106 | "run_attempt": 1, 107 | "html_url": "https://github.com/cli/cli/actions/runs/8116997610", 108 | "run_started_at": "2024-03-01T20:44:44Z", 109 | "duration": 0 110 | }, 111 | { 112 | "id": 8116985595, 113 | "status": "completed", 114 | "conclusion": "failure", 115 | "actor": "xxx", 116 | "run_attempt": 1, 117 | "html_url": "https://github.com/cli/cli/actions/runs/8116985595", 118 | "run_started_at": "2024-03-01T20:43:13Z", 119 | "duration": 0 120 | }, 121 | { 122 | "id": 8116985358, 123 | "status": "completed", 124 | "conclusion": "failure", 125 | "actor": "xxx", 126 | "run_attempt": 1, 127 | "html_url": "https://github.com/cli/cli/actions/runs/8116985358", 128 | "run_started_at": "2024-03-01T20:43:11Z", 129 | "duration": 0 130 | }, 131 | { 132 | "id": 8116020627, 133 | "status": "completed", 134 | "conclusion": "failure", 135 | "actor": "xxx", 136 | "run_attempt": 1, 137 | "html_url": "https://github.com/cli/cli/actions/runs/8116020627", 138 | "run_started_at": "2024-03-01T19:06:51Z", 139 | "duration": 0 140 | }, 141 | { 142 | "id": 8116020395, 143 | "status": "completed", 144 | "conclusion": "failure", 145 | "actor": "xxx", 146 | "run_attempt": 1, 147 | "html_url": "https://github.com/cli/cli/actions/runs/8116020395", 148 | "run_started_at": "2024-03-01T19:06:50Z", 149 | "duration": 0 150 | }, 151 | { 152 | "id": 8068240447, 153 | "status": "completed", 154 | "conclusion": "failure", 155 | "actor": "xxx", 156 | "run_attempt": 1, 157 | "html_url": "https://github.com/cli/cli/actions/runs/8068240447", 158 | "run_started_at": "2024-02-27T16:39:45Z", 159 | "duration": 0 160 | } 161 | ] 162 | }, 163 | "others": { 164 | "runs_count": 7, 165 | "workflow_runs": [ 166 | { 167 | "id": 8123276222, 168 | "status": "completed", 169 | "conclusion": "action_required", 170 | "actor": "xxx", 171 | "run_attempt": 1, 172 | "html_url": "https://github.com/cli/cli/actions/runs/8123276222", 173 | "run_started_at": "2024-03-02T14:02:57Z", 174 | "duration": 0 175 | }, 176 | { 177 | "id": 8081862941, 178 | "status": "completed", 179 | "conclusion": "action_required", 180 | "actor": "xxx", 181 | "run_attempt": 1, 182 | "html_url": "https://github.com/cli/cli/actions/runs/8081862941", 183 | "run_started_at": "2024-02-28T14:19:14Z", 184 | "duration": 0 185 | }, 186 | { 187 | "id": 8058366605, 188 | "status": "completed", 189 | "conclusion": "action_required", 190 | "actor": "xxx", 191 | "run_attempt": 1, 192 | "html_url": "https://github.com/cli/cli/actions/runs/8058366605", 193 | "run_started_at": "2024-02-27T01:45:37Z", 194 | "duration": 0 195 | }, 196 | { 197 | "id": 8050688367, 198 | "status": "completed", 199 | "conclusion": "action_required", 200 | "actor": "xxx", 201 | "run_attempt": 1, 202 | "html_url": "https://github.com/cli/cli/actions/runs/8050688367", 203 | "run_started_at": "2024-02-26T14:41:26Z", 204 | "duration": 0 205 | }, 206 | { 207 | "id": 8039041836, 208 | "status": "completed", 209 | "conclusion": "action_required", 210 | "actor": "xxx", 211 | "run_attempt": 1, 212 | "html_url": "https://github.com/cli/cli/actions/runs/8039041836", 213 | "run_started_at": "2024-02-25T15:38:21Z", 214 | "duration": 0 215 | }, 216 | { 217 | "id": 7964544095, 218 | "status": "completed", 219 | "conclusion": "action_required", 220 | "actor": "xxx", 221 | "run_attempt": 1, 222 | "html_url": "https://github.com/cli/cli/actions/runs/7964544095", 223 | "run_started_at": "2024-02-19T19:50:30Z", 224 | "duration": 0 225 | }, 226 | { 227 | "id": 7904859017, 228 | "status": "completed", 229 | "conclusion": "action_required", 230 | "actor": "xxx", 231 | "run_attempt": 1, 232 | "html_url": "https://github.com/cli/cli/actions/runs/7904859017", 233 | "run_started_at": "2024-02-14T17:12:34Z", 234 | "duration": 0 235 | } 236 | ] 237 | }, 238 | "success": { 239 | "runs_count": 79, 240 | "workflow_runs": [ 241 | { 242 | "id": 8246798031, 243 | "status": "completed", 244 | "conclusion": "success", 245 | "actor": "xxx", 246 | "run_attempt": 1, 247 | "html_url": "https://github.com/cli/cli/actions/runs/8246798031", 248 | "run_started_at": "2024-03-12T09:53:48Z", 249 | "duration": 474 250 | }, 251 | { 252 | "id": 8246669599, 253 | "status": "completed", 254 | "conclusion": "success", 255 | "actor": "xxx", 256 | "run_attempt": 1, 257 | "html_url": "https://github.com/cli/cli/actions/runs/8246669599", 258 | "run_started_at": "2024-03-12T09:43:33Z", 259 | "duration": 409 260 | }, 261 | { 262 | "id": 8246642128, 263 | "status": "completed", 264 | "conclusion": "success", 265 | "actor": "xxx", 266 | "run_attempt": 1, 267 | "html_url": "https://github.com/cli/cli/actions/runs/8246642128", 268 | "run_started_at": "2024-03-12T09:41:17Z", 269 | "duration": 447 270 | }, 271 | { 272 | "id": 8192902898, 273 | "status": "completed", 274 | "conclusion": "success", 275 | "actor": "xxx", 276 | "run_attempt": 1, 277 | "html_url": "https://github.com/cli/cli/actions/runs/8192902898", 278 | "run_started_at": "2024-03-07T18:17:22Z", 279 | "duration": 705 280 | }, 281 | { 282 | "id": 8186205239, 283 | "status": "completed", 284 | "conclusion": "success", 285 | "actor": "xxx", 286 | "run_attempt": 1, 287 | "html_url": "https://github.com/cli/cli/actions/runs/8186205239", 288 | "run_started_at": "2024-03-07T09:58:05Z", 289 | "duration": 418 290 | }, 291 | { 292 | "id": 8179466125, 293 | "status": "completed", 294 | "conclusion": "success", 295 | "actor": "xxx", 296 | "run_attempt": 2, 297 | "html_url": "https://github.com/cli/cli/actions/runs/8179466125", 298 | "run_started_at": "2024-03-07T09:33:50Z", 299 | "duration": 508 300 | }, 301 | { 302 | "id": 8179178378, 303 | "status": "completed", 304 | "conclusion": "success", 305 | "actor": "xxx", 306 | "run_attempt": 1, 307 | "html_url": "https://github.com/cli/cli/actions/runs/8179178378", 308 | "run_started_at": "2024-03-06T21:44:00Z", 309 | "duration": 841 310 | }, 311 | { 312 | "id": 8179154612, 313 | "status": "completed", 314 | "conclusion": "success", 315 | "actor": "xxx", 316 | "run_attempt": 1, 317 | "html_url": "https://github.com/cli/cli/actions/runs/8179154612", 318 | "run_started_at": "2024-03-06T21:41:37Z", 319 | "duration": 605 320 | }, 321 | { 322 | "id": 8178743869, 323 | "status": "completed", 324 | "conclusion": "success", 325 | "actor": "xxx", 326 | "run_attempt": 1, 327 | "html_url": "https://github.com/cli/cli/actions/runs/8178743869", 328 | "run_started_at": "2024-03-06T21:05:21Z", 329 | "duration": 864 330 | }, 331 | { 332 | "id": 8178674132, 333 | "status": "completed", 334 | "conclusion": "success", 335 | "actor": "xxx", 336 | "run_attempt": 1, 337 | "html_url": "https://github.com/cli/cli/actions/runs/8178674132", 338 | "run_started_at": "2024-03-06T20:59:49Z", 339 | "duration": 492 340 | }, 341 | { 342 | "id": 8177104548, 343 | "status": "completed", 344 | "conclusion": "success", 345 | "actor": "xxx", 346 | "run_attempt": 1, 347 | "html_url": "https://github.com/cli/cli/actions/runs/8177104548", 348 | "run_started_at": "2024-03-06T18:39:39Z", 349 | "duration": 492 350 | }, 351 | { 352 | "id": 8176532027, 353 | "status": "completed", 354 | "conclusion": "success", 355 | "actor": "xxx", 356 | "run_attempt": 1, 357 | "html_url": "https://github.com/cli/cli/actions/runs/8176532027", 358 | "run_started_at": "2024-03-06T17:53:17Z", 359 | "duration": 586 360 | }, 361 | { 362 | "id": 8174963304, 363 | "status": "completed", 364 | "conclusion": "success", 365 | "actor": "xxx", 366 | "run_attempt": 1, 367 | "html_url": "https://github.com/cli/cli/actions/runs/8174963304", 368 | "run_started_at": "2024-03-06T15:57:13Z", 369 | "duration": 446 370 | }, 371 | { 372 | "id": 8174595802, 373 | "status": "completed", 374 | "conclusion": "success", 375 | "actor": "xxx", 376 | "run_attempt": 2, 377 | "html_url": "https://github.com/cli/cli/actions/runs/8174595802", 378 | "run_started_at": "2024-03-06T15:48:57Z", 379 | "duration": 491 380 | }, 381 | { 382 | "id": 8165180304, 383 | "status": "completed", 384 | "conclusion": "success", 385 | "actor": "xxx", 386 | "run_attempt": 1, 387 | "html_url": "https://github.com/cli/cli/actions/runs/8165180304", 388 | "run_started_at": "2024-03-06T00:48:34Z", 389 | "duration": 599 390 | }, 391 | { 392 | "id": 8164388036, 393 | "status": "completed", 394 | "conclusion": "success", 395 | "actor": "xxx", 396 | "run_attempt": 1, 397 | "html_url": "https://github.com/cli/cli/actions/runs/8164388036", 398 | "run_started_at": "2024-03-05T23:22:02Z", 399 | "duration": 577 400 | }, 401 | { 402 | "id": 8164376957, 403 | "status": "completed", 404 | "conclusion": "success", 405 | "actor": "xxx", 406 | "run_attempt": 1, 407 | "html_url": "https://github.com/cli/cli/actions/runs/8164376957", 408 | "run_started_at": "2024-03-05T23:20:53Z", 409 | "duration": 600 410 | }, 411 | { 412 | "id": 8163359586, 413 | "status": "completed", 414 | "conclusion": "success", 415 | "actor": "xxx", 416 | "run_attempt": 1, 417 | "html_url": "https://github.com/cli/cli/actions/runs/8163359586", 418 | "run_started_at": "2024-03-05T21:39:35Z", 419 | "duration": 501 420 | }, 421 | { 422 | "id": 8160053868, 423 | "status": "completed", 424 | "conclusion": "success", 425 | "actor": "xxx", 426 | "run_attempt": 1, 427 | "html_url": "https://github.com/cli/cli/actions/runs/8160053868", 428 | "run_started_at": "2024-03-05T16:58:13Z", 429 | "duration": 776 430 | }, 431 | { 432 | "id": 8148535708, 433 | "status": "completed", 434 | "conclusion": "success", 435 | "actor": "xxx", 436 | "run_attempt": 1, 437 | "html_url": "https://github.com/cli/cli/actions/runs/8148535708", 438 | "run_started_at": "2024-03-04T23:34:56Z", 439 | "duration": 463 440 | }, 441 | { 442 | "id": 8148387445, 443 | "status": "completed", 444 | "conclusion": "success", 445 | "actor": "xxx", 446 | "run_attempt": 1, 447 | "html_url": "https://github.com/cli/cli/actions/runs/8148387445", 448 | "run_started_at": "2024-03-04T23:18:05Z", 449 | "duration": 556 450 | }, 451 | { 452 | "id": 8148079961, 453 | "status": "completed", 454 | "conclusion": "success", 455 | "actor": "xxx", 456 | "run_attempt": 1, 457 | "html_url": "https://github.com/cli/cli/actions/runs/8148079961", 458 | "run_started_at": "2024-03-04T22:45:58Z", 459 | "duration": 469 460 | }, 461 | { 462 | "id": 8148056194, 463 | "status": "completed", 464 | "conclusion": "success", 465 | "actor": "xxx", 466 | "run_attempt": 1, 467 | "html_url": "https://github.com/cli/cli/actions/runs/8148056194", 468 | "run_started_at": "2024-03-04T22:43:02Z", 469 | "duration": 552 470 | }, 471 | { 472 | "id": 8148048382, 473 | "status": "completed", 474 | "conclusion": "success", 475 | "actor": "xxx", 476 | "run_attempt": 1, 477 | "html_url": "https://github.com/cli/cli/actions/runs/8148048382", 478 | "run_started_at": "2024-03-04T22:42:16Z", 479 | "duration": 586 480 | }, 481 | { 482 | "id": 8148013236, 483 | "status": "completed", 484 | "conclusion": "success", 485 | "actor": "xxx", 486 | "run_attempt": 1, 487 | "html_url": "https://github.com/cli/cli/actions/runs/8148013236", 488 | "run_started_at": "2024-03-04T22:38:22Z", 489 | "duration": 596 490 | }, 491 | { 492 | "id": 8147999082, 493 | "status": "completed", 494 | "conclusion": "success", 495 | "actor": "xxx", 496 | "run_attempt": 1, 497 | "html_url": "https://github.com/cli/cli/actions/runs/8147999082", 498 | "run_started_at": "2024-03-04T22:36:54Z", 499 | "duration": 619 500 | }, 501 | { 502 | "id": 8147187440, 503 | "status": "completed", 504 | "conclusion": "success", 505 | "actor": "xxx", 506 | "run_attempt": 1, 507 | "html_url": "https://github.com/cli/cli/actions/runs/8147187440", 508 | "run_started_at": "2024-03-04T21:22:32Z", 509 | "duration": 472 510 | }, 511 | { 512 | "id": 8144107435, 513 | "status": "completed", 514 | "conclusion": "success", 515 | "actor": "xxx", 516 | "run_attempt": 1, 517 | "html_url": "https://github.com/cli/cli/actions/runs/8144107435", 518 | "run_started_at": "2024-03-04T17:04:17Z", 519 | "duration": 439 520 | }, 521 | { 522 | "id": 8143929999, 523 | "status": "completed", 524 | "conclusion": "success", 525 | "actor": "xxx", 526 | "run_attempt": 1, 527 | "html_url": "https://github.com/cli/cli/actions/runs/8143929999", 528 | "run_started_at": "2024-03-04T16:50:51Z", 529 | "duration": 420 530 | }, 531 | { 532 | "id": 8143929780, 533 | "status": "completed", 534 | "conclusion": "success", 535 | "actor": "xxx", 536 | "run_attempt": 1, 537 | "html_url": "https://github.com/cli/cli/actions/runs/8143929780", 538 | "run_started_at": "2024-03-04T16:50:50Z", 539 | "duration": 404 540 | }, 541 | { 542 | "id": 8143862131, 543 | "status": "completed", 544 | "conclusion": "success", 545 | "actor": "xxx", 546 | "run_attempt": 1, 547 | "html_url": "https://github.com/cli/cli/actions/runs/8143862131", 548 | "run_started_at": "2024-03-04T16:45:48Z", 549 | "duration": 431 550 | }, 551 | { 552 | "id": 8143861993, 553 | "status": "completed", 554 | "conclusion": "success", 555 | "actor": "xxx", 556 | "run_attempt": 1, 557 | "html_url": "https://github.com/cli/cli/actions/runs/8143861993", 558 | "run_started_at": "2024-03-04T16:45:47Z", 559 | "duration": 435 560 | }, 561 | { 562 | "id": 8117176855, 563 | "status": "completed", 564 | "conclusion": "success", 565 | "actor": "xxx", 566 | "run_attempt": 1, 567 | "html_url": "https://github.com/cli/cli/actions/runs/8117176855", 568 | "run_started_at": "2024-03-01T21:04:45Z", 569 | "duration": 477 570 | }, 571 | { 572 | "id": 8117176632, 573 | "status": "completed", 574 | "conclusion": "success", 575 | "actor": "xxx", 576 | "run_attempt": 1, 577 | "html_url": "https://github.com/cli/cli/actions/runs/8117176632", 578 | "run_started_at": "2024-03-01T21:04:44Z", 579 | "duration": 445 580 | }, 581 | { 582 | "id": 8114415849, 583 | "status": "completed", 584 | "conclusion": "success", 585 | "actor": "xxx", 586 | "run_attempt": 1, 587 | "html_url": "https://github.com/cli/cli/actions/runs/8114415849", 588 | "run_started_at": "2024-03-01T16:45:33Z", 589 | "duration": 460 590 | }, 591 | { 592 | "id": 8114215617, 593 | "status": "completed", 594 | "conclusion": "success", 595 | "actor": "xxx", 596 | "run_attempt": 1, 597 | "html_url": "https://github.com/cli/cli/actions/runs/8114215617", 598 | "run_started_at": "2024-03-01T16:29:31Z", 599 | "duration": 473 600 | }, 601 | { 602 | "id": 8113390433, 603 | "status": "completed", 604 | "conclusion": "success", 605 | "actor": "xxx", 606 | "run_attempt": 1, 607 | "html_url": "https://github.com/cli/cli/actions/runs/8113390433", 608 | "run_started_at": "2024-03-01T15:33:20Z", 609 | "duration": 1080 610 | }, 611 | { 612 | "id": 8111431760, 613 | "status": "completed", 614 | "conclusion": "success", 615 | "actor": "xxxe4", 616 | "run_attempt": 1, 617 | "html_url": "https://github.com/cli/cli/actions/runs/8111431760", 618 | "run_started_at": "2024-03-01T12:35:27Z", 619 | "duration": 434 620 | }, 621 | { 622 | "id": 8111431426, 623 | "status": "completed", 624 | "conclusion": "success", 625 | "actor": "xxxe4", 626 | "run_attempt": 1, 627 | "html_url": "https://github.com/cli/cli/actions/runs/8111431426", 628 | "run_started_at": "2024-03-01T12:35:25Z", 629 | "duration": 400 630 | }, 631 | { 632 | "id": 8111412034, 633 | "status": "completed", 634 | "conclusion": "success", 635 | "actor": "xxxe4", 636 | "run_attempt": 1, 637 | "html_url": "https://github.com/cli/cli/actions/runs/8111412034", 638 | "run_started_at": "2024-03-01T12:33:50Z", 639 | "duration": 423 640 | }, 641 | { 642 | "id": 8111405462, 643 | "status": "completed", 644 | "conclusion": "success", 645 | "actor": "xxxe4", 646 | "run_attempt": 1, 647 | "html_url": "https://github.com/cli/cli/actions/runs/8111405462", 648 | "run_started_at": "2024-03-01T12:33:14Z", 649 | "duration": 440 650 | }, 651 | { 652 | "id": 8110395969, 653 | "status": "completed", 654 | "conclusion": "success", 655 | "actor": "xxx", 656 | "run_attempt": 1, 657 | "html_url": "https://github.com/cli/cli/actions/runs/8110395969", 658 | "run_started_at": "2024-03-01T11:04:45Z", 659 | "duration": 435 660 | }, 661 | { 662 | "id": 8109285997, 663 | "status": "completed", 664 | "conclusion": "success", 665 | "actor": "xxx", 666 | "run_attempt": 1, 667 | "html_url": "https://github.com/cli/cli/actions/runs/8109285997", 668 | "run_started_at": "2024-03-01T09:30:25Z", 669 | "duration": 476 670 | }, 671 | { 672 | "id": 8105813816, 673 | "status": "completed", 674 | "conclusion": "success", 675 | "actor": "xxx", 676 | "run_attempt": 2, 677 | "html_url": "https://github.com/cli/cli/actions/runs/8105813816", 678 | "run_started_at": "2024-03-01T10:57:52Z", 679 | "duration": 365 680 | }, 681 | { 682 | "id": 8103574346, 683 | "status": "completed", 684 | "conclusion": "success", 685 | "actor": "xxx", 686 | "run_attempt": 2, 687 | "html_url": "https://github.com/cli/cli/actions/runs/8103574346", 688 | "run_started_at": "2024-03-01T09:19:55Z", 689 | "duration": 416 690 | }, 691 | { 692 | "id": 8098952808, 693 | "status": "completed", 694 | "conclusion": "success", 695 | "actor": "xxx", 696 | "run_attempt": 1, 697 | "html_url": "https://github.com/cli/cli/actions/runs/8098952808", 698 | "run_started_at": "2024-02-29T16:02:43Z", 699 | "duration": 466 700 | }, 701 | { 702 | "id": 8098776700, 703 | "status": "completed", 704 | "conclusion": "success", 705 | "actor": "xxx", 706 | "run_attempt": 1, 707 | "html_url": "https://github.com/cli/cli/actions/runs/8098776700", 708 | "run_started_at": "2024-02-29T15:49:33Z", 709 | "duration": 464 710 | }, 711 | { 712 | "id": 8096585346, 713 | "status": "completed", 714 | "conclusion": "success", 715 | "actor": "xxx", 716 | "run_attempt": 1, 717 | "html_url": "https://github.com/cli/cli/actions/runs/8096585346", 718 | "run_started_at": "2024-02-29T13:10:13Z", 719 | "duration": 435 720 | }, 721 | { 722 | "id": 8096033209, 723 | "status": "completed", 724 | "conclusion": "success", 725 | "actor": "xxx", 726 | "run_attempt": 1, 727 | "html_url": "https://github.com/cli/cli/actions/runs/8096033209", 728 | "run_started_at": "2024-02-29T12:30:54Z", 729 | "duration": 462 730 | }, 731 | { 732 | "id": 8095669015, 733 | "status": "completed", 734 | "conclusion": "success", 735 | "actor": "xxx", 736 | "run_attempt": 1, 737 | "html_url": "https://github.com/cli/cli/actions/runs/8095669015", 738 | "run_started_at": "2024-02-29T12:04:04Z", 739 | "duration": 413 740 | }, 741 | { 742 | "id": 8080694587, 743 | "status": "completed", 744 | "conclusion": "success", 745 | "actor": "xxx", 746 | "run_attempt": 1, 747 | "html_url": "https://github.com/cli/cli/actions/runs/8080694587", 748 | "run_started_at": "2024-02-28T12:53:32Z", 749 | "duration": 505 750 | }, 751 | { 752 | "id": 8080496874, 753 | "status": "completed", 754 | "conclusion": "success", 755 | "actor": "xxx", 756 | "run_attempt": 1, 757 | "html_url": "https://github.com/cli/cli/actions/runs/8080496874", 758 | "run_started_at": "2024-02-28T12:39:02Z", 759 | "duration": 467 760 | }, 761 | { 762 | "id": 8080496476, 763 | "status": "completed", 764 | "conclusion": "success", 765 | "actor": "xxx", 766 | "run_attempt": 1, 767 | "html_url": "https://github.com/cli/cli/actions/runs/8080496476", 768 | "run_started_at": "2024-02-28T12:39:01Z", 769 | "duration": 621 770 | }, 771 | { 772 | "id": 8070600072, 773 | "status": "completed", 774 | "conclusion": "success", 775 | "actor": "xxx", 776 | "run_attempt": 1, 777 | "html_url": "https://github.com/cli/cli/actions/runs/8070600072", 778 | "run_started_at": "2024-02-27T19:57:54Z", 779 | "duration": 782 780 | }, 781 | { 782 | "id": 8070546820, 783 | "status": "completed", 784 | "conclusion": "success", 785 | "actor": "xxx", 786 | "run_attempt": 1, 787 | "html_url": "https://github.com/cli/cli/actions/runs/8070546820", 788 | "run_started_at": "2024-02-27T19:52:20Z", 789 | "duration": 673 790 | }, 791 | { 792 | "id": 8068506595, 793 | "status": "completed", 794 | "conclusion": "success", 795 | "actor": "xxx", 796 | "run_attempt": 1, 797 | "html_url": "https://github.com/cli/cli/actions/runs/8068506595", 798 | "run_started_at": "2024-02-27T17:01:03Z", 799 | "duration": 454 800 | }, 801 | { 802 | "id": 8062636694, 803 | "status": "completed", 804 | "conclusion": "success", 805 | "actor": "xxx", 806 | "run_attempt": 2, 807 | "html_url": "https://github.com/cli/cli/actions/runs/8062636694", 808 | "run_started_at": "2024-02-29T12:32:33Z", 809 | "duration": 369 810 | }, 811 | { 812 | "id": 7997027570, 813 | "status": "completed", 814 | "conclusion": "success", 815 | "actor": "xxx", 816 | "run_attempt": 2, 817 | "html_url": "https://github.com/cli/cli/actions/runs/7997027570", 818 | "run_started_at": "2024-02-29T12:12:39Z", 819 | "duration": 456 820 | }, 821 | { 822 | "id": 7974641106, 823 | "status": "completed", 824 | "conclusion": "success", 825 | "actor": "xxx", 826 | "run_attempt": 1, 827 | "html_url": "https://github.com/cli/cli/actions/runs/7974641106", 828 | "run_started_at": "2024-02-20T13:58:34Z", 829 | "duration": 447 830 | }, 831 | { 832 | "id": 7973334002, 833 | "status": "completed", 834 | "conclusion": "success", 835 | "actor": "xxx", 836 | "run_attempt": 1, 837 | "html_url": "https://github.com/cli/cli/actions/runs/7973334002", 838 | "run_started_at": "2024-02-20T12:19:40Z", 839 | "duration": 429 840 | }, 841 | { 842 | "id": 7956431056, 843 | "status": "completed", 844 | "conclusion": "success", 845 | "actor": "xxx", 846 | "run_attempt": 1, 847 | "html_url": "https://github.com/cli/cli/actions/runs/7956431056", 848 | "run_started_at": "2024-02-19T08:12:18Z", 849 | "duration": 623 850 | }, 851 | { 852 | "id": 7951751260, 853 | "status": "completed", 854 | "conclusion": "success", 855 | "actor": "xxx", 856 | "run_attempt": 1, 857 | "html_url": "https://github.com/cli/cli/actions/runs/7951751260", 858 | "run_started_at": "2024-02-18T21:12:14Z", 859 | "duration": 420 860 | }, 861 | { 862 | "id": 7951100756, 863 | "status": "completed", 864 | "conclusion": "success", 865 | "actor": "xxx", 866 | "run_attempt": 1, 867 | "html_url": "https://github.com/cli/cli/actions/runs/7951100756", 868 | "run_started_at": "2024-02-18T19:05:11Z", 869 | "duration": 404 870 | }, 871 | { 872 | "id": 7947556381, 873 | "status": "completed", 874 | "conclusion": "success", 875 | "actor": "xxx", 876 | "run_attempt": 1, 877 | "html_url": "https://github.com/cli/cli/actions/runs/7947556381", 878 | "run_started_at": "2024-02-18T07:59:58Z", 879 | "duration": 454 880 | }, 881 | { 882 | "id": 7947161233, 883 | "status": "completed", 884 | "conclusion": "success", 885 | "actor": "xxx", 886 | "run_attempt": 2, 887 | "html_url": "https://github.com/cli/cli/actions/runs/7947161233", 888 | "run_started_at": "2024-02-20T13:49:56Z", 889 | "duration": 444 890 | }, 891 | { 892 | "id": 7941757547, 893 | "status": "completed", 894 | "conclusion": "success", 895 | "actor": "xxx", 896 | "run_attempt": 1, 897 | "html_url": "https://github.com/cli/cli/actions/runs/7941757547", 898 | "run_started_at": "2024-02-17T12:45:57Z", 899 | "duration": 408 900 | }, 901 | { 902 | "id": 7941535420, 903 | "status": "completed", 904 | "conclusion": "success", 905 | "actor": "xxx", 906 | "run_attempt": 1, 907 | "html_url": "https://github.com/cli/cli/actions/runs/7941535420", 908 | "run_started_at": "2024-02-17T12:10:24Z", 909 | "duration": 499 910 | }, 911 | { 912 | "id": 7941531226, 913 | "status": "completed", 914 | "conclusion": "success", 915 | "actor": "xxx", 916 | "run_attempt": 1, 917 | "html_url": "https://github.com/cli/cli/actions/runs/7941531226", 918 | "run_started_at": "2024-02-17T12:09:44Z", 919 | "duration": 460 920 | }, 921 | { 922 | "id": 7937887459, 923 | "status": "completed", 924 | "conclusion": "success", 925 | "actor": "xxx", 926 | "run_attempt": 1, 927 | "html_url": "https://github.com/cli/cli/actions/runs/7937887459", 928 | "run_started_at": "2024-02-17T00:30:21Z", 929 | "duration": 469 930 | }, 931 | { 932 | "id": 7937041403, 933 | "status": "completed", 934 | "conclusion": "success", 935 | "actor": "xxx", 936 | "run_attempt": 2, 937 | "html_url": "https://github.com/cli/cli/actions/runs/7937041403", 938 | "run_started_at": "2024-02-28T13:03:33Z", 939 | "duration": 637 940 | }, 941 | { 942 | "id": 7933914619, 943 | "status": "completed", 944 | "conclusion": "success", 945 | "actor": "xxx", 946 | "run_attempt": 1, 947 | "html_url": "https://github.com/cli/cli/actions/runs/7933914619", 948 | "run_started_at": "2024-02-16T17:12:30Z", 949 | "duration": 427 950 | }, 951 | { 952 | "id": 7933725165, 953 | "status": "completed", 954 | "conclusion": "success", 955 | "actor": "xxx", 956 | "run_attempt": 1, 957 | "html_url": "https://github.com/cli/cli/actions/runs/7933725165", 958 | "run_started_at": "2024-02-16T16:56:31Z", 959 | "duration": 436 960 | }, 961 | { 962 | "id": 7933198277, 963 | "status": "completed", 964 | "conclusion": "success", 965 | "actor": "xxx", 966 | "run_attempt": 1, 967 | "html_url": "https://github.com/cli/cli/actions/runs/7933198277", 968 | "run_started_at": "2024-02-16T16:09:58Z", 969 | "duration": 467 970 | }, 971 | { 972 | "id": 7918272309, 973 | "status": "completed", 974 | "conclusion": "success", 975 | "actor": "xxx", 976 | "run_attempt": 1, 977 | "html_url": "https://github.com/cli/cli/actions/runs/7918272309", 978 | "run_started_at": "2024-02-15T15:25:09Z", 979 | "duration": 436 980 | }, 981 | { 982 | "id": 7914612249, 983 | "status": "completed", 984 | "conclusion": "success", 985 | "actor": "xxx", 986 | "run_attempt": 1, 987 | "html_url": "https://github.com/cli/cli/actions/runs/7914612249", 988 | "run_started_at": "2024-02-15T10:39:00Z", 989 | "duration": 454 990 | }, 991 | { 992 | "id": 7913415580, 993 | "status": "completed", 994 | "conclusion": "success", 995 | "actor": "xxx", 996 | "run_attempt": 1, 997 | "html_url": "https://github.com/cli/cli/actions/runs/7913415580", 998 | "run_started_at": "2024-02-15T09:01:47Z", 999 | "duration": 465 1000 | }, 1001 | { 1002 | "id": 7896535739, 1003 | "status": "completed", 1004 | "conclusion": "success", 1005 | "actor": "xxx", 1006 | "run_attempt": 2, 1007 | "html_url": "https://github.com/cli/cli/actions/runs/7896535739", 1008 | "run_started_at": "2024-02-28T12:57:04Z", 1009 | "duration": 459 1010 | }, 1011 | { 1012 | "id": 7870446056, 1013 | "status": "completed", 1014 | "conclusion": "success", 1015 | "actor": "xxx", 1016 | "run_attempt": 1, 1017 | "html_url": "https://github.com/cli/cli/actions/runs/7870446056", 1018 | "run_started_at": "2024-02-12T10:34:18Z", 1019 | "duration": 496 1020 | }, 1021 | { 1022 | "id": 7870435756, 1023 | "status": "completed", 1024 | "conclusion": "success", 1025 | "actor": "xxx", 1026 | "run_attempt": 1, 1027 | "html_url": "https://github.com/cli/cli/actions/runs/7870435756", 1028 | "run_started_at": "2024-02-12T10:33:26Z", 1029 | "duration": 440 1030 | } 1031 | ] 1032 | } 1033 | } 1034 | }, 1035 | "workflow_jobs_stats_summary": [ 1036 | { 1037 | "name": "build (ubuntu-latest)", 1038 | "total_runs_count": 93, 1039 | "rate": { 1040 | "success_rate": 0.8494623655913979, 1041 | "failure_rate": 0.15053763440860216, 1042 | "others_rate": 0 1043 | }, 1044 | "conclusions": { 1045 | "failure": 14, 1046 | "success": 79 1047 | }, 1048 | "execution_duration_stats": { 1049 | "min": 166, 1050 | "max": 225, 1051 | "avg": 185.2405063291139, 1052 | "med": 178, 1053 | "std": 15.9367541045597 1054 | }, 1055 | "steps_summary": [ 1056 | { 1057 | "name": "Set up job", 1058 | "number": 1, 1059 | "runs_count": 93, 1060 | "conclusion": { 1061 | "success": 93 1062 | }, 1063 | "rate": { 1064 | "success_rate": 1, 1065 | "failure_rate": 0, 1066 | "others_rate": 0 1067 | }, 1068 | "execution_duration_stats": { 1069 | "min": 0, 1070 | "max": 3, 1071 | "avg": 1.3225806451612903, 1072 | "med": 1, 1073 | "std": 0.6584649846191345 1074 | }, 1075 | "failure_html_url": [] 1076 | }, 1077 | { 1078 | "name": "Set up Go 1.21", 1079 | "number": 2, 1080 | "runs_count": 93, 1081 | "conclusion": { 1082 | "success": 93 1083 | }, 1084 | "rate": { 1085 | "success_rate": 1, 1086 | "failure_rate": 0, 1087 | "others_rate": 0 1088 | }, 1089 | "execution_duration_stats": { 1090 | "min": 0, 1091 | "max": 12, 1092 | "avg": 1.5698924731182795, 1093 | "med": 1, 1094 | "std": 1.9587018383775974 1095 | }, 1096 | "failure_html_url": [] 1097 | }, 1098 | { 1099 | "name": "Check out code", 1100 | "number": 3, 1101 | "runs_count": 93, 1102 | "conclusion": { 1103 | "success": 93 1104 | }, 1105 | "rate": { 1106 | "success_rate": 1, 1107 | "failure_rate": 0, 1108 | "others_rate": 0 1109 | }, 1110 | "execution_duration_stats": { 1111 | "min": 0, 1112 | "max": 2, 1113 | "avg": 0.5376344086021505, 1114 | "med": 1, 1115 | "std": 0.539994818472676 1116 | }, 1117 | "failure_html_url": [] 1118 | }, 1119 | { 1120 | "name": "Restore Go modules cache", 1121 | "number": 4, 1122 | "runs_count": 93, 1123 | "conclusion": { 1124 | "success": 93 1125 | }, 1126 | "rate": { 1127 | "success_rate": 1, 1128 | "failure_rate": 0, 1129 | "others_rate": 0 1130 | }, 1131 | "execution_duration_stats": { 1132 | "min": 1, 1133 | "max": 8, 1134 | "avg": 3.4516129032258065, 1135 | "med": 3, 1136 | "std": 1.5897259453562684 1137 | }, 1138 | "failure_html_url": [] 1139 | }, 1140 | { 1141 | "name": "Download dependencies", 1142 | "number": 5, 1143 | "runs_count": 93, 1144 | "conclusion": { 1145 | "failure": 2, 1146 | "success": 91 1147 | }, 1148 | "rate": { 1149 | "success_rate": 0.978494623655914, 1150 | "failure_rate": 0.021505376344086023, 1151 | "others_rate": 0 1152 | }, 1153 | "execution_duration_stats": { 1154 | "min": 0, 1155 | "max": 4, 1156 | "avg": 0.053763440860215055, 1157 | "med": 0, 1158 | "std": 0.4241523209315271 1159 | }, 1160 | "failure_html_url": [ 1161 | "https://github.com/cli/cli/actions/runs/8117020991/job/22188309868", 1162 | "https://github.com/cli/cli/actions/runs/8116997610/job/22188235639" 1163 | ] 1164 | }, 1165 | { 1166 | "name": "Run tests", 1167 | "number": 6, 1168 | "runs_count": 93, 1169 | "conclusion": { 1170 | "failure": 6, 1171 | "others": 2, 1172 | "success": 85 1173 | }, 1174 | "rate": { 1175 | "success_rate": 0.9139784946236559, 1176 | "failure_rate": 0.06451612903225806, 1177 | "others_rate": 0.021505376344086058 1178 | }, 1179 | "execution_duration_stats": { 1180 | "min": 130, 1181 | "max": 163, 1182 | "avg": 141.67032967032966, 1183 | "med": 137, 1184 | "std": 10.102828398501044 1185 | }, 1186 | "failure_html_url": [ 1187 | "https://github.com/cli/cli/actions/runs/8116985595/job/22188194097", 1188 | "https://github.com/cli/cli/actions/runs/8068240447/job/22040506851", 1189 | "https://github.com/cli/cli/actions/runs/8116985358/job/22188193249", 1190 | "https://github.com/cli/cli/actions/runs/8116020395/job/22185129915", 1191 | "https://github.com/cli/cli/actions/runs/8174112382/job/22348132574", 1192 | "https://github.com/cli/cli/actions/runs/8116020627/job/22185130738" 1193 | ] 1194 | }, 1195 | { 1196 | "name": "Build", 1197 | "number": 7, 1198 | "runs_count": 93, 1199 | "conclusion": { 1200 | "others": 8, 1201 | "success": 85 1202 | }, 1203 | "rate": { 1204 | "success_rate": 0.9139784946236559, 1205 | "failure_rate": 0, 1206 | "others_rate": 0.08602150537634412 1207 | }, 1208 | "execution_duration_stats": { 1209 | "min": 29, 1210 | "max": 43, 1211 | "avg": 33.50588235294118, 1212 | "med": 30, 1213 | "std": 5.171468171127294 1214 | }, 1215 | "failure_html_url": [] 1216 | }, 1217 | { 1218 | "name": "Build executable", 1219 | "number": 8, 1220 | "runs_count": 2, 1221 | "conclusion": { 1222 | "success": 2 1223 | }, 1224 | "rate": { 1225 | "success_rate": 1, 1226 | "failure_rate": 0, 1227 | "others_rate": 0 1228 | }, 1229 | "execution_duration_stats": { 1230 | "min": 42, 1231 | "max": 42, 1232 | "avg": 42, 1233 | "med": 42, 1234 | "std": 0 1235 | }, 1236 | "failure_html_url": [] 1237 | }, 1238 | { 1239 | "name": "Run attestation command integration Tests", 1240 | "number": 9, 1241 | "runs_count": 6, 1242 | "conclusion": { 1243 | "failure": 6 1244 | }, 1245 | "rate": { 1246 | "success_rate": 0, 1247 | "failure_rate": 1, 1248 | "others_rate": 0 1249 | }, 1250 | "execution_duration_stats": { 1251 | "min": 0, 1252 | "max": 3, 1253 | "avg": 1.1666666666666667, 1254 | "med": 1, 1255 | "std": 1.0671873729054748 1256 | }, 1257 | "failure_html_url": [ 1258 | "https://github.com/cli/cli/actions/runs/8178494723/job/22362522362", 1259 | "https://github.com/cli/cli/actions/runs/8178432563/job/22362314351", 1260 | "https://github.com/cli/cli/actions/runs/8178314603/job/22361955316", 1261 | "https://github.com/cli/cli/actions/runs/8178247079/job/22361739816", 1262 | "https://github.com/cli/cli/actions/runs/8178615249/job/22362923334", 1263 | "https://github.com/cli/cli/actions/runs/8178250009/job/22361747921" 1264 | ] 1265 | }, 1266 | { 1267 | "name": "Post Restore Go modules cache", 1268 | "number": 12, 1269 | "runs_count": 93, 1270 | "conclusion": { 1271 | "others": 14, 1272 | "success": 79 1273 | }, 1274 | "rate": { 1275 | "success_rate": 0.8494623655913979, 1276 | "failure_rate": 0, 1277 | "others_rate": 0.15053763440860213 1278 | }, 1279 | "execution_duration_stats": { 1280 | "min": 0, 1281 | "max": 9, 1282 | "avg": 0.43037974683544306, 1283 | "med": 0, 1284 | "std": 1.472821663766764 1285 | }, 1286 | "failure_html_url": [] 1287 | }, 1288 | { 1289 | "name": "Post Check out code", 1290 | "number": 13, 1291 | "runs_count": 93, 1292 | "conclusion": { 1293 | "success": 93 1294 | }, 1295 | "rate": { 1296 | "success_rate": 1, 1297 | "failure_rate": 0, 1298 | "others_rate": 0 1299 | }, 1300 | "execution_duration_stats": { 1301 | "min": 0, 1302 | "max": 1, 1303 | "avg": 0.043010752688172046, 1304 | "med": 0, 1305 | "std": 0.20288131466788403 1306 | }, 1307 | "failure_html_url": [] 1308 | }, 1309 | { 1310 | "name": "Post Set up Go 1.21", 1311 | "number": 14, 1312 | "runs_count": 93, 1313 | "conclusion": { 1314 | "others": 14, 1315 | "success": 79 1316 | }, 1317 | "rate": { 1318 | "success_rate": 0.8494623655913979, 1319 | "failure_rate": 0, 1320 | "others_rate": 0.15053763440860213 1321 | }, 1322 | "execution_duration_stats": { 1323 | "min": 0, 1324 | "max": 1, 1325 | "avg": 0.012658227848101266, 1326 | "med": 0, 1327 | "std": 0.11179444134592216 1328 | }, 1329 | "failure_html_url": [] 1330 | }, 1331 | { 1332 | "name": "Complete job", 1333 | "number": 15, 1334 | "runs_count": 93, 1335 | "conclusion": { 1336 | "success": 93 1337 | }, 1338 | "rate": { 1339 | "success_rate": 1, 1340 | "failure_rate": 0, 1341 | "others_rate": 0 1342 | }, 1343 | "execution_duration_stats": { 1344 | "min": 0, 1345 | "max": 1, 1346 | "avg": 0.053763440860215055, 1347 | "med": 0, 1348 | "std": 0.2255502899290651 1349 | }, 1350 | "failure_html_url": [] 1351 | } 1352 | ] 1353 | }, 1354 | { 1355 | "name": "build (windows-latest)", 1356 | "total_runs_count": 93, 1357 | "rate": { 1358 | "success_rate": 0.8709677419354839, 1359 | "failure_rate": 0.12903225806451613, 1360 | "others_rate": 0 1361 | }, 1362 | "conclusions": { 1363 | "failure": 12, 1364 | "success": 81 1365 | }, 1366 | "execution_duration_stats": { 1367 | "min": 352, 1368 | "max": 850, 1369 | "avg": 480.8641975308642, 1370 | "med": 450, 1371 | "std": 99.60499182870082 1372 | }, 1373 | "steps_summary": [ 1374 | { 1375 | "name": "Set up job", 1376 | "number": 1, 1377 | "runs_count": 93, 1378 | "conclusion": { 1379 | "success": 93 1380 | }, 1381 | "rate": { 1382 | "success_rate": 1, 1383 | "failure_rate": 0, 1384 | "others_rate": 0 1385 | }, 1386 | "execution_duration_stats": { 1387 | "min": 1, 1388 | "max": 3, 1389 | "avg": 1.6236559139784945, 1390 | "med": 2, 1391 | "std": 0.6031098349410818 1392 | }, 1393 | "failure_html_url": [] 1394 | }, 1395 | { 1396 | "name": "Set up Go 1.21", 1397 | "number": 2, 1398 | "runs_count": 93, 1399 | "conclusion": { 1400 | "success": 93 1401 | }, 1402 | "rate": { 1403 | "success_rate": 1, 1404 | "failure_rate": 0, 1405 | "others_rate": 0 1406 | }, 1407 | "execution_duration_stats": { 1408 | "min": 3, 1409 | "max": 53, 1410 | "avg": 8.763440860215054, 1411 | "med": 7, 1412 | "std": 6.624162108852099 1413 | }, 1414 | "failure_html_url": [] 1415 | }, 1416 | { 1417 | "name": "Check out code", 1418 | "number": 3, 1419 | "runs_count": 93, 1420 | "conclusion": { 1421 | "success": 93 1422 | }, 1423 | "rate": { 1424 | "success_rate": 1, 1425 | "failure_rate": 0, 1426 | "others_rate": 0 1427 | }, 1428 | "execution_duration_stats": { 1429 | "min": 3, 1430 | "max": 12, 1431 | "avg": 4.182795698924731, 1432 | "med": 4, 1433 | "std": 1.163478623202063 1434 | }, 1435 | "failure_html_url": [] 1436 | }, 1437 | { 1438 | "name": "Restore Go modules cache", 1439 | "number": 4, 1440 | "runs_count": 93, 1441 | "conclusion": { 1442 | "success": 93 1443 | }, 1444 | "rate": { 1445 | "success_rate": 1, 1446 | "failure_rate": 0, 1447 | "others_rate": 0 1448 | }, 1449 | "execution_duration_stats": { 1450 | "min": 19, 1451 | "max": 260, 1452 | "avg": 60.763440860215056, 1453 | "med": 45, 1454 | "std": 44.482544081307076 1455 | }, 1456 | "failure_html_url": [] 1457 | }, 1458 | { 1459 | "name": "Download dependencies", 1460 | "number": 5, 1461 | "runs_count": 93, 1462 | "conclusion": { 1463 | "failure": 2, 1464 | "success": 91 1465 | }, 1466 | "rate": { 1467 | "success_rate": 0.978494623655914, 1468 | "failure_rate": 0.021505376344086023, 1469 | "others_rate": 0 1470 | }, 1471 | "execution_duration_stats": { 1472 | "min": 0, 1473 | "max": 22, 1474 | "avg": 0.8924731182795699, 1475 | "med": 0, 1476 | "std": 2.4690007799569287 1477 | }, 1478 | "failure_html_url": [ 1479 | "https://github.com/cli/cli/actions/runs/8117020991/job/22188310163", 1480 | "https://github.com/cli/cli/actions/runs/8116997610/job/22188235830" 1481 | ] 1482 | }, 1483 | { 1484 | "name": "Run tests", 1485 | "number": 6, 1486 | "runs_count": 93, 1487 | "conclusion": { 1488 | "failure": 6, 1489 | "others": 2, 1490 | "success": 85 1491 | }, 1492 | "rate": { 1493 | "success_rate": 0.9139784946236559, 1494 | "failure_rate": 0.06451612903225806, 1495 | "others_rate": 0.021505376344086058 1496 | }, 1497 | "execution_duration_stats": { 1498 | "min": 276, 1499 | "max": 620, 1500 | "avg": 359.16483516483515, 1501 | "med": 344, 1502 | "std": 61.442417201524854 1503 | }, 1504 | "failure_html_url": [ 1505 | "https://github.com/cli/cli/actions/runs/8116985595/job/22188194271", 1506 | "https://github.com/cli/cli/actions/runs/8068240447/job/22040507179", 1507 | "https://github.com/cli/cli/actions/runs/8116985358/job/22188193401", 1508 | "https://github.com/cli/cli/actions/runs/8116020395/job/22185130105", 1509 | "https://github.com/cli/cli/actions/runs/8174112382/job/22348133113", 1510 | "https://github.com/cli/cli/actions/runs/8116020627/job/22185130973" 1511 | ] 1512 | }, 1513 | { 1514 | "name": "Build", 1515 | "number": 7, 1516 | "runs_count": 93, 1517 | "conclusion": { 1518 | "others": 8, 1519 | "success": 85 1520 | }, 1521 | "rate": { 1522 | "success_rate": 0.9139784946236559, 1523 | "failure_rate": 0, 1524 | "others_rate": 0.08602150537634412 1525 | }, 1526 | "execution_duration_stats": { 1527 | "min": 37, 1528 | "max": 62, 1529 | "avg": 44.56470588235294, 1530 | "med": 40, 1531 | "std": 7.8985435125330055 1532 | }, 1533 | "failure_html_url": [] 1534 | }, 1535 | { 1536 | "name": "Build executable", 1537 | "number": 8, 1538 | "runs_count": 2, 1539 | "conclusion": { 1540 | "failure": 2 1541 | }, 1542 | "rate": { 1543 | "success_rate": 0, 1544 | "failure_rate": 1, 1545 | "others_rate": 0 1546 | }, 1547 | "execution_duration_stats": { 1548 | "min": 0, 1549 | "max": 0, 1550 | "avg": 0, 1551 | "med": 0, 1552 | "std": 0 1553 | }, 1554 | "failure_html_url": [ 1555 | "https://github.com/cli/cli/actions/runs/8178494723/job/22362522725", 1556 | "https://github.com/cli/cli/actions/runs/8178615249/job/22362923737" 1557 | ] 1558 | }, 1559 | { 1560 | "name": "Run attestation command integration Tests", 1561 | "number": 9, 1562 | "runs_count": 6, 1563 | "conclusion": { 1564 | "failure": 2, 1565 | "others": 2, 1566 | "success": 2 1567 | }, 1568 | "rate": { 1569 | "success_rate": 0.3333333333333333, 1570 | "failure_rate": 0.3333333333333333, 1571 | "others_rate": 0.3333333333333334 1572 | }, 1573 | "execution_duration_stats": { 1574 | "min": 0, 1575 | "max": 1, 1576 | "avg": 0.25, 1577 | "med": 0, 1578 | "std": 0.4330127018922193 1579 | }, 1580 | "failure_html_url": [ 1581 | "https://github.com/cli/cli/actions/runs/8178247079/job/22361740207", 1582 | "https://github.com/cli/cli/actions/runs/8178250009/job/22361748166" 1583 | ] 1584 | }, 1585 | { 1586 | "name": "Post Restore Go modules cache", 1587 | "number": 12, 1588 | "runs_count": 93, 1589 | "conclusion": { 1590 | "others": 12, 1591 | "success": 81 1592 | }, 1593 | "rate": { 1594 | "success_rate": 0.8709677419354839, 1595 | "failure_rate": 0, 1596 | "others_rate": 0.12903225806451613 1597 | }, 1598 | "execution_duration_stats": { 1599 | "min": 0, 1600 | "max": 15, 1601 | "avg": 0.8765432098765432, 1602 | "med": 0, 1603 | "std": 2.530713635991271 1604 | }, 1605 | "failure_html_url": [] 1606 | }, 1607 | { 1608 | "name": "Post Check out code", 1609 | "number": 13, 1610 | "runs_count": 93, 1611 | "conclusion": { 1612 | "success": 93 1613 | }, 1614 | "rate": { 1615 | "success_rate": 1, 1616 | "failure_rate": 0, 1617 | "others_rate": 0 1618 | }, 1619 | "execution_duration_stats": { 1620 | "min": 0, 1621 | "max": 1, 1622 | "avg": 0.7956989247311828, 1623 | "med": 1, 1624 | "std": 0.403189962564574 1625 | }, 1626 | "failure_html_url": [] 1627 | }, 1628 | { 1629 | "name": "Post Set up Go 1.21", 1630 | "number": 14, 1631 | "runs_count": 93, 1632 | "conclusion": { 1633 | "others": 12, 1634 | "success": 81 1635 | }, 1636 | "rate": { 1637 | "success_rate": 0.8709677419354839, 1638 | "failure_rate": 0, 1639 | "others_rate": 0.12903225806451613 1640 | }, 1641 | "execution_duration_stats": { 1642 | "min": 0, 1643 | "max": 1, 1644 | "avg": 0.1111111111111111, 1645 | "med": 0, 1646 | "std": 0.3142696805273545 1647 | }, 1648 | "failure_html_url": [] 1649 | }, 1650 | { 1651 | "name": "Complete job", 1652 | "number": 15, 1653 | "runs_count": 93, 1654 | "conclusion": { 1655 | "success": 93 1656 | }, 1657 | "rate": { 1658 | "success_rate": 1, 1659 | "failure_rate": 0, 1660 | "others_rate": 0 1661 | }, 1662 | "execution_duration_stats": { 1663 | "min": 0, 1664 | "max": 0, 1665 | "avg": 0, 1666 | "med": 0, 1667 | "std": 0 1668 | }, 1669 | "failure_html_url": [] 1670 | } 1671 | ] 1672 | }, 1673 | { 1674 | "name": "build (macos-latest)", 1675 | "total_runs_count": 93, 1676 | "rate": { 1677 | "success_rate": 0.8494623655913979, 1678 | "failure_rate": 0.15053763440860216, 1679 | "others_rate": 0 1680 | }, 1681 | "conclusions": { 1682 | "failure": 14, 1683 | "success": 79 1684 | }, 1685 | "execution_duration_stats": { 1686 | "min": 196, 1687 | "max": 602, 1688 | "avg": 301.6455696202532, 1689 | "med": 266, 1690 | "std": 92.25803631761225 1691 | }, 1692 | "steps_summary": [ 1693 | { 1694 | "name": "Set up job", 1695 | "number": 1, 1696 | "runs_count": 93, 1697 | "conclusion": { 1698 | "success": 93 1699 | }, 1700 | "rate": { 1701 | "success_rate": 1, 1702 | "failure_rate": 0, 1703 | "others_rate": 0 1704 | }, 1705 | "execution_duration_stats": { 1706 | "min": 2, 1707 | "max": 6, 1708 | "avg": 3.10752688172043, 1709 | "med": 3, 1710 | "std": 0.9667856685156568 1711 | }, 1712 | "failure_html_url": [] 1713 | }, 1714 | { 1715 | "name": "Set up Go 1.21", 1716 | "number": 2, 1717 | "runs_count": 93, 1718 | "conclusion": { 1719 | "success": 93 1720 | }, 1721 | "rate": { 1722 | "success_rate": 1, 1723 | "failure_rate": 0, 1724 | "others_rate": 0 1725 | }, 1726 | "execution_duration_stats": { 1727 | "min": 3, 1728 | "max": 10, 1729 | "avg": 4.795698924731183, 1730 | "med": 4, 1731 | "std": 1.6497581431407027 1732 | }, 1733 | "failure_html_url": [] 1734 | }, 1735 | { 1736 | "name": "Check out code", 1737 | "number": 3, 1738 | "runs_count": 93, 1739 | "conclusion": { 1740 | "success": 93 1741 | }, 1742 | "rate": { 1743 | "success_rate": 1, 1744 | "failure_rate": 0, 1745 | "others_rate": 0 1746 | }, 1747 | "execution_duration_stats": { 1748 | "min": 0, 1749 | "max": 3, 1750 | "avg": 1.3655913978494623, 1751 | "med": 1, 1752 | "std": 0.6184431761096091 1753 | }, 1754 | "failure_html_url": [] 1755 | }, 1756 | { 1757 | "name": "Restore Go modules cache", 1758 | "number": 4, 1759 | "runs_count": 93, 1760 | "conclusion": { 1761 | "success": 93 1762 | }, 1763 | "rate": { 1764 | "success_rate": 1, 1765 | "failure_rate": 0, 1766 | "others_rate": 0 1767 | }, 1768 | "execution_duration_stats": { 1769 | "min": 7, 1770 | "max": 45, 1771 | "avg": 16.537634408602152, 1772 | "med": 12, 1773 | "std": 8.779936977490701 1774 | }, 1775 | "failure_html_url": [] 1776 | }, 1777 | { 1778 | "name": "Download dependencies", 1779 | "number": 5, 1780 | "runs_count": 93, 1781 | "conclusion": { 1782 | "failure": 2, 1783 | "success": 91 1784 | }, 1785 | "rate": { 1786 | "success_rate": 0.978494623655914, 1787 | "failure_rate": 0.021505376344086023, 1788 | "others_rate": 0 1789 | }, 1790 | "execution_duration_stats": { 1791 | "min": 0, 1792 | "max": 17, 1793 | "avg": 0.24731182795698925, 1794 | "med": 0, 1795 | "std": 1.7879880804640678 1796 | }, 1797 | "failure_html_url": [ 1798 | "https://github.com/cli/cli/actions/runs/8117020991/job/22188310423", 1799 | "https://github.com/cli/cli/actions/runs/8116997610/job/22188235988" 1800 | ] 1801 | }, 1802 | { 1803 | "name": "Run tests", 1804 | "number": 6, 1805 | "runs_count": 93, 1806 | "conclusion": { 1807 | "failure": 6, 1808 | "others": 2, 1809 | "success": 85 1810 | }, 1811 | "rate": { 1812 | "success_rate": 0.9139784946236559, 1813 | "failure_rate": 0.06451612903225806, 1814 | "others_rate": 0.021505376344086058 1815 | }, 1816 | "execution_duration_stats": { 1817 | "min": 145, 1818 | "max": 464, 1819 | "avg": 214.98901098901098, 1820 | "med": 199, 1821 | "std": 57.194769319260274 1822 | }, 1823 | "failure_html_url": [ 1824 | "https://github.com/cli/cli/actions/runs/8116985595/job/22188194429", 1825 | "https://github.com/cli/cli/actions/runs/8068240447/job/22040507450", 1826 | "https://github.com/cli/cli/actions/runs/8116985358/job/22188193614", 1827 | "https://github.com/cli/cli/actions/runs/8116020395/job/22185130333", 1828 | "https://github.com/cli/cli/actions/runs/8174112382/job/22348133494", 1829 | "https://github.com/cli/cli/actions/runs/8116020627/job/22185131209" 1830 | ] 1831 | }, 1832 | { 1833 | "name": "Build", 1834 | "number": 7, 1835 | "runs_count": 93, 1836 | "conclusion": { 1837 | "others": 8, 1838 | "success": 85 1839 | }, 1840 | "rate": { 1841 | "success_rate": 0.9139784946236559, 1842 | "failure_rate": 0, 1843 | "others_rate": 0.08602150537634412 1844 | }, 1845 | "execution_duration_stats": { 1846 | "min": 25, 1847 | "max": 185, 1848 | "avg": 55.38823529411765, 1849 | "med": 47, 1850 | "std": 31.81900805110683 1851 | }, 1852 | "failure_html_url": [] 1853 | }, 1854 | { 1855 | "name": "Build executable", 1856 | "number": 8, 1857 | "runs_count": 2, 1858 | "conclusion": { 1859 | "success": 2 1860 | }, 1861 | "rate": { 1862 | "success_rate": 1, 1863 | "failure_rate": 0, 1864 | "others_rate": 0 1865 | }, 1866 | "execution_duration_stats": { 1867 | "min": 51, 1868 | "max": 79, 1869 | "avg": 65, 1870 | "med": 65, 1871 | "std": 14 1872 | }, 1873 | "failure_html_url": [] 1874 | }, 1875 | { 1876 | "name": "Run attestation command integration Tests", 1877 | "number": 9, 1878 | "runs_count": 6, 1879 | "conclusion": { 1880 | "failure": 6 1881 | }, 1882 | "rate": { 1883 | "success_rate": 0, 1884 | "failure_rate": 1, 1885 | "others_rate": 0 1886 | }, 1887 | "execution_duration_stats": { 1888 | "min": 0, 1889 | "max": 2, 1890 | "avg": 1, 1891 | "med": 1, 1892 | "std": 0.816496580927726 1893 | }, 1894 | "failure_html_url": [ 1895 | "https://github.com/cli/cli/actions/runs/8178494723/job/22362523001", 1896 | "https://github.com/cli/cli/actions/runs/8178432563/job/22362314886", 1897 | "https://github.com/cli/cli/actions/runs/8178314603/job/22361955852", 1898 | "https://github.com/cli/cli/actions/runs/8178247079/job/22361740468", 1899 | "https://github.com/cli/cli/actions/runs/8178615249/job/22362924033", 1900 | "https://github.com/cli/cli/actions/runs/8178250009/job/22361748390" 1901 | ] 1902 | }, 1903 | { 1904 | "name": "Post Restore Go modules cache", 1905 | "number": 12, 1906 | "runs_count": 93, 1907 | "conclusion": { 1908 | "others": 14, 1909 | "success": 79 1910 | }, 1911 | "rate": { 1912 | "success_rate": 0.8494623655913979, 1913 | "failure_rate": 0, 1914 | "others_rate": 0.15053763440860213 1915 | }, 1916 | "execution_duration_stats": { 1917 | "min": 0, 1918 | "max": 18, 1919 | "avg": 0.8227848101265823, 1920 | "med": 0, 1921 | "std": 2.750187536394363 1922 | }, 1923 | "failure_html_url": [] 1924 | }, 1925 | { 1926 | "name": "Post Check out code", 1927 | "number": 13, 1928 | "runs_count": 93, 1929 | "conclusion": { 1930 | "success": 93 1931 | }, 1932 | "rate": { 1933 | "success_rate": 1, 1934 | "failure_rate": 0, 1935 | "others_rate": 0 1936 | }, 1937 | "execution_duration_stats": { 1938 | "min": 0, 1939 | "max": 1, 1940 | "avg": 0.10752688172043011, 1941 | "med": 0, 1942 | "std": 0.3097819417395254 1943 | }, 1944 | "failure_html_url": [] 1945 | }, 1946 | { 1947 | "name": "Post Set up Go 1.21", 1948 | "number": 14, 1949 | "runs_count": 93, 1950 | "conclusion": { 1951 | "others": 14, 1952 | "success": 79 1953 | }, 1954 | "rate": { 1955 | "success_rate": 0.8494623655913979, 1956 | "failure_rate": 0, 1957 | "others_rate": 0.15053763440860213 1958 | }, 1959 | "execution_duration_stats": { 1960 | "min": 0, 1961 | "max": 1, 1962 | "avg": 0.012658227848101266, 1963 | "med": 0, 1964 | "std": 0.11179444134592213 1965 | }, 1966 | "failure_html_url": [] 1967 | }, 1968 | { 1969 | "name": "Complete job", 1970 | "number": 15, 1971 | "runs_count": 93, 1972 | "conclusion": { 1973 | "success": 93 1974 | }, 1975 | "rate": { 1976 | "success_rate": 1, 1977 | "failure_rate": 0, 1978 | "others_rate": 0 1979 | }, 1980 | "execution_duration_stats": { 1981 | "min": 0, 1982 | "max": 2, 1983 | "avg": 0.34408602150537637, 1984 | "med": 0, 1985 | "std": 0.5387086042957138 1986 | }, 1987 | "failure_html_url": [] 1988 | } 1989 | ] 1990 | } 1991 | ] 1992 | } 1993 | -------------------------------------------------------------------------------- /sample/std-output.txt: -------------------------------------------------------------------------------- 1 | 🏃 Total runs: 100 2 | ✔ Success: 79 (79.0%) 3 | ✖ Failure: 14 (14.0%) 4 | 🤔Others : 7 (7.0%) 5 | 6 | ⏰ Workflow run execution time stats 7 | Min: 365.0s 8 | Max: 1080.0s 9 | Avg: 505.5s 10 | Med: 464.0s 11 | Std: 120.8s 12 | 13 | 📈 Top 3 jobs with the highest failure counts (failure runs / total runs) 14 | build (macos-latest) 15 | └──Run tests: 6/93 16 | 17 | build (ubuntu-latest) 18 | └──Run tests: 6/93 19 | 20 | build (windows-latest) 21 | └──Run tests: 6/93 22 | 23 | 24 | 📊 Top 3 jobs with the longest execution average duration 25 | build (windows-latest): 480.86s 26 | build (macos-latest): 301.65s 27 | build (ubuntu-latest): 185.24s 28 | --------------------------------------------------------------------------------