├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── continuous-integration.yml │ ├── conventional-commits.yml │ ├── release.yml │ └── stale-issues.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── ghrepo.go ├── ghrepo_test.go ├── graphql.go ├── root.go ├── root_test.go ├── suite_test.go ├── ui.go └── ui_test.go ├── go.mod ├── go.sum ├── main.go └── scripts └── gofmtcheck /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "38 2 * * 4" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v3 26 | with: 27 | languages: go 28 | 29 | - name: Perform CodeQL Analysis 30 | uses: github/codeql-action/analyze@v3 31 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version-file: go.mod 17 | 18 | - uses: actions/cache@v4 19 | with: 20 | path: ~/go/pkg/mod 21 | key: go-${{ hashFiles('**/go.sum') }} 22 | restore-keys: go- 23 | 24 | - run: make fmtcheck 25 | - run: make cover 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - uses: codecov/codecov-action@v5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | fail_ci_if_error: true 33 | -------------------------------------------------------------------------------- /.github/workflows/conventional-commits.yml: -------------------------------------------------------------------------------- 1 | name: conventional-commits 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: amannn/action-semantic-pull-request@v5 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | types: | 19 | build 20 | chore 21 | ci 22 | docs 23 | feat 24 | fix 25 | refactor 26 | test 27 | scopes: | 28 | cmd 29 | deps 30 | requireScope: false 31 | validateSingleCommit: true 32 | validateSingleCommitMatchesPrTitle: true 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version-file: go.mod 18 | 19 | - uses: goreleaser/goreleaser-action@v6 20 | with: 21 | args: release --rm-dist 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: stale-issues 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | /gh-metrics 8 | /gh-metrics.exe 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | # Go workspace file 20 | go.work 21 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - linux 10 | - windows 11 | - netbsd 12 | archives: 13 | - name_template: "{{ .Os }}-{{ .Arch }}" 14 | format: binary 15 | snapshot: 16 | name_template: "{{ .Tag }}-next" 17 | changelog: 18 | use: github-native 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hector@castro.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2022 Hector Castro 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST?=./... 2 | GOFMT_FILES?=$$(find . -type f -name '*.go') 3 | 4 | default: test 5 | 6 | test: fmtcheck 7 | go list $(TEST) | xargs -t -n4 go test $(TESTARGS) -v -timeout=2m -parallel=4 8 | 9 | cover: 10 | go test $(TEST) -race -coverprofile=coverage.out -covermode=atomic 11 | 12 | fmt: 13 | gofmt -w $(GOFMT_FILES) 14 | 15 | fmtcheck: 16 | @sh -c "'$(CURDIR)/scripts/gofmtcheck'" 17 | 18 | .NOTPARALLEL: 19 | 20 | .PHONY: cover default fmt fmtcheck test 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gh-metrics 2 | 3 | A [`gh`](https://cli.github.com/) extension that provides summary pull request metrics. 4 | 5 | - [Usage](#usage) 6 | - [Metric definitions](#metric-definitions) 7 | - [Influences](#influences) 8 | 9 | ## Usage 10 | 11 | To install the extension use: 12 | 13 | ```console 14 | $ gh extension install hectcastro/gh-metrics 15 | ``` 16 | 17 | Once installed, you can summarize all pull requests for the `cli/cli` repository over the last 10 days: 18 | 19 | ```console 20 | $ gh metrics --repo cli/cli 21 | ┌──────┬─────────┬───────────┬───────────┬───────────────┬──────────────────────┬──────────┬──────────────┬───────────────────┬──────────────────────┬─────────────────────────┐ 22 | │ PR │ COMMITS │ ADDITIONS │ DELETIONS │ CHANGED FILES │ TIME TO FIRST REVIEW │ COMMENTS │ PARTICIPANTS │ FEATURE LEAD TIME │ FIRST TO LAST REVIEW │ FIRST APPROVAL TO MERGE │ 23 | ├──────┼─────────┼───────────┼───────────┼───────────────┼──────────────────────┼──────────┼──────────────┼───────────────────┼──────────────────────┼─────────────────────────┤ 24 | │ 6029 │ 1 │ 3 │ 2 │ 1 │ 26m │ 1 │ 4 │ 1h9m │ 40m │ 40m │ 25 | │ 6019 │ 2 │ 8 │ 0 │ 1 │ 19h13m │ 1 │ 4 │ 23h15m │ -- │ 3h58m │ 26 | │ 6008 │ 1 │ 1 │ 12 │ 2 │ 12h19m │ 1 │ 4 │ 185h5m │ 167h54m │ 4h51m │ 27 | │ 6004 │ 1 │ 18 │ 0 │ 1 │ 149h59m │ 3 │ 5 │ 208h47m │ 6h7m │ 58h48m │ 28 | │ 5974 │ 1 │ 1 │ 1 │ 1 │ 130h54m │ 1 │ 5 │ 262h58m │ 6h55m │ 178h34m │ 29 | └──────┴─────────┴───────────┴───────────┴───────────────┴──────────────────────┴──────────┴──────────────┴───────────────────┴──────────────────────┴─────────────────────────┘ 30 | ``` 31 | 32 | Or, within a more precise window of time: 33 | 34 | ```console 35 | $ gh metrics --repo cli/cli --start 2022-03-21 --end 2022-03-22 36 | ┌──────┬─────────┬───────────┬───────────┬───────────────┬──────────────────────┬──────────┬──────────────┬───────────────────┬──────────────────────┬─────────────────────────┐ 37 | │ PR │ COMMITS │ ADDITIONS │ DELETIONS │ CHANGED FILES │ TIME TO FIRST REVIEW │ COMMENTS │ PARTICIPANTS │ FEATURE LEAD TIME │ FIRST TO LAST REVIEW │ FIRST APPROVAL TO MERGE │ 38 | ├──────┼─────────┼───────────┼───────────┼───────────────┼──────────────────────┼──────────┼──────────────┼───────────────────┼──────────────────────┼─────────────────────────┤ 39 | │ 5339 │ 4 │ 6 │ 3 │ 1 │ 2m │ 0 │ 3 │ 1h12m │ 59m │ 1h9m │ 40 | │ 5336 │ 1 │ 2 │ 2 │ 2 │ 7m │ 0 │ 1 │ 2h30m │ -- │ 2h24m │ 41 | │ 5327 │ 1 │ 1 │ 1 │ 1 │ 41h57m │ 1 │ 4 │ 65h44m │ 23h21m │ 23h36m │ 42 | └──────┴─────────┴───────────┴───────────┴───────────────┴──────────────────────┴──────────┴──────────────┴───────────────────┴──────────────────────┴─────────────────────────┘ 43 | ``` 44 | 45 | Or, with an additional query filter: 46 | 47 | ```console 48 | $ gh metrics --repo cli/cli --start 2022-03-21 --end 2022-03-22 --query "author:josebalius" 49 | ┌──────┬─────────┬───────────┬───────────┬───────────────┬──────────────────────┬──────────┬──────────────┬───────────────────┬──────────────────────┬─────────────────────────┐ 50 | │ PR │ COMMITS │ ADDITIONS │ DELETIONS │ CHANGED FILES │ TIME TO FIRST REVIEW │ COMMENTS │ PARTICIPANTS │ FEATURE LEAD TIME │ FIRST TO LAST REVIEW │ FIRST APPROVAL TO MERGE │ 51 | ├──────┼─────────┼───────────┼───────────┼───────────────┼──────────────────────┼──────────┼──────────────┼───────────────────┼──────────────────────┼─────────────────────────┤ 52 | │ 5339 │ 4 │ 6 │ 3 │ 1 │ 2m │ 0 │ 3 │ 1h12m │ 59m │ 1h9m │ 53 | └──────┴─────────┴───────────┴───────────┴───────────────┴──────────────────────┴──────────┴──────────────┴───────────────────┴──────────────────────┴─────────────────────────┘ 54 | ``` 55 | 56 | Alternatively, instead of the default table output, output can be generated in CSV format: 57 | 58 | ```console 59 | $ gh metrics --repo cli/cli --start 2022-03-21 --end 2022-03-22 --csv 60 | PR,Commits,Additions,Deletions,Changed Files,Time to First Review,Comments,Participants,Feature Lead Time,First to Last Review,First Approval to Merge 61 | 5339,4,6,3,1,00:02,0,3,01:12,00:59,01:09 62 | 5336,1,2,2,2,00:07,0,1,02:30,00:00,02:24 63 | 5327,1,1,1,1,41:57,1,4,65:44,23:21,23:36 64 | ``` 65 | 66 | ## Metric definitions 67 | 68 | - **Time to first review**: The duration from when the pull request was created or marked *Ready for review* to when the first review against it was completed. 69 | - **Feature lead time**: The duration from when the first commit contained in the pull request was created to when the pull request was merged. 70 | - **First review to last review**: The duration between the first non-author review and the last approving non-author review ([Background](https://github.com/hectcastro/gh-metrics/issues/13)) 71 | - **First approval to merge**: The duration from when the first approval review is given to when the pull request is merged. 72 | 73 | ## Influences 74 | 75 | Development of this extension was heavily inspired by [jmartin82/mkpis](https://github.com/jmartin82/mkpis). 76 | -------------------------------------------------------------------------------- /cmd/ghrepo.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/cli/go-gh/pkg/auth" 8 | ) 9 | 10 | // GHRepo represents a GitHub repository. 11 | type GHRepo struct { 12 | Owner string 13 | Name string 14 | Host string 15 | } 16 | 17 | // newGHRepo returns a GHRepo configured via the '/'-delimited string it's passed. 18 | func newGHRepo(name string) (*GHRepo, error) { 19 | defaultHost, _ := auth.DefaultHost() 20 | nameParts := strings.Split(name, "/") 21 | 22 | switch len(nameParts) { 23 | case 2: 24 | return &GHRepo{ 25 | Owner: nameParts[0], 26 | Name: nameParts[1], 27 | Host: defaultHost, 28 | }, nil 29 | case 3: 30 | return &GHRepo{ 31 | Owner: nameParts[1], 32 | Name: nameParts[2], 33 | Host: nameParts[0], 34 | }, nil 35 | default: 36 | return nil, errors.New("invalid repository name") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/ghrepo_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nbio/st" 7 | ) 8 | 9 | func TestNewGHRepo(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | wantName string 13 | wantOwner string 14 | wantHost string 15 | wantFullName string 16 | wantErr bool 17 | errMsg string 18 | }{ 19 | { 20 | name: "foo/bar", 21 | wantOwner: "foo", 22 | wantName: "bar", 23 | wantHost: "github.com", 24 | wantErr: false, 25 | }, { 26 | name: "other-github.com/foo/bar", 27 | wantOwner: "foo", 28 | wantName: "bar", 29 | wantHost: "other-github.com", 30 | wantErr: false, 31 | }, { 32 | name: "bar", 33 | wantErr: true, 34 | errMsg: "invalid repository name", 35 | }, { 36 | name: "", 37 | wantErr: true, 38 | errMsg: "invalid repository name", 39 | }} 40 | 41 | for _, tt := range tests { 42 | tt := tt 43 | 44 | t.Run(tt.name, func(t *testing.T) { 45 | t.Parallel() 46 | 47 | ghr, err := newGHRepo(tt.name) 48 | 49 | if tt.wantErr { 50 | st.Assert(t, err.Error(), tt.errMsg) 51 | 52 | } else { 53 | st.Assert(t, err, nil) 54 | st.Assert(t, ghr.Owner, tt.wantOwner) 55 | st.Assert(t, ghr.Name, tt.wantName) 56 | st.Assert(t, ghr.Host, tt.wantHost) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cmd/graphql.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type PageInfo struct { 4 | HasNextPage bool 5 | EndCursor string 6 | } 7 | 8 | type Author struct { 9 | Login string 10 | } 11 | 12 | type Participants struct { 13 | TotalCount int 14 | } 15 | 16 | type Comments struct { 17 | TotalCount int 18 | } 19 | 20 | type ReviewNodes []struct { 21 | Author Author 22 | CreatedAt string 23 | State string 24 | } 25 | 26 | type Reviews struct { 27 | TotalCount int 28 | Nodes ReviewNodes 29 | } 30 | 31 | type Commit struct { 32 | CommittedDate string 33 | } 34 | 35 | type CommitNodes []struct { 36 | Commit Commit 37 | } 38 | 39 | type Commits struct { 40 | TotalCount int 41 | Nodes CommitNodes 42 | } 43 | 44 | type ReadyForReviewEvent struct { 45 | CreatedAt string 46 | } 47 | 48 | type TimelineItemNodes []struct { 49 | ReadyForReviewEvent ReadyForReviewEvent `graphql:"... on ReadyForReviewEvent"` 50 | } 51 | 52 | type TimelineItems struct { 53 | TotalCount int 54 | Nodes TimelineItemNodes 55 | } 56 | 57 | type MetricsGQLQuery struct { 58 | Search struct { 59 | PageInfo PageInfo 60 | Nodes []struct { 61 | PullRequest struct { 62 | Author Author 63 | Additions int 64 | Deletions int 65 | Number int 66 | CreatedAt string 67 | ChangedFiles int 68 | IsDraft bool 69 | MergedAt string 70 | Participants Participants 71 | Comments Comments 72 | Reviews Reviews `graphql:"reviews(first: 50, states: [APPROVED, CHANGES_REQUESTED, COMMENTED])"` 73 | Commits Commits `graphql:"commits(first: 1)"` 74 | TimelineItems TimelineItems `graphql:"timelineItems(first: 1, itemTypes: [READY_FOR_REVIEW_EVENT])"` 75 | } `graphql:"... on PullRequest"` 76 | } 77 | } `graphql:"search(query: $query, type: ISSUE, last: $resultCount, after: $afterCursor)"` 78 | } 79 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd implements a command line interface for summarizing 2 | // GitHub pull request metrics. 3 | package cmd 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | 9 | gh "github.com/cli/go-gh" 10 | "github.com/rickar/cal/v2" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const ( 15 | // Extension version. Displayed when `--version` flag is used. 16 | Version = "3.0.0" 17 | // Default number of days in the past to look for pull requests 18 | // within a repository. 19 | DefaultDaysBack = 10 20 | // Default date format to use when displaying dates. 21 | DefaultDateFormat = "2006-01-02" 22 | ) 23 | 24 | var ( 25 | // defaultStart is the default start date to query pull requests if none is 26 | // specified. 27 | defaultStart string 28 | // defaultEnd is the default end date to query pull requests if none is 29 | // specified. 30 | defaultEnd string 31 | ) 32 | 33 | // WorkdayOnlyWeekdays returns true if the given day is a weekday, 34 | // otherwise returns false. 35 | func WorkdayOnlyWeekdays(d time.Time) bool { 36 | return d.Weekday() != time.Saturday && d.Weekday() != time.Sunday 37 | } 38 | 39 | // WorkdayAllDays returns true regardless of the given day, as currently 40 | // all days are considered workdays. 41 | func WorkdayAllDays(d time.Time) bool { 42 | return true 43 | } 44 | 45 | // WorkdayStart determines the beginning of a workday by returning the 46 | // same day, but at the first second. 47 | func WorkdayStart(d time.Time) time.Time { 48 | year, month, day := d.Date() 49 | return time.Date(year, month, day, 0, 0, 0, 0, d.Location()) 50 | } 51 | 52 | // WorkdayEnd determines the end of a workday by returning the same day, 53 | // but at the last second. 54 | func WorkdayEnd(d time.Time) time.Time { 55 | year, month, day := d.Date() 56 | return time.Date(year, month, day, 23, 59, 59, 0, d.Location()) 57 | } 58 | 59 | var RootCmd = &cobra.Command{ 60 | Use: "gh-metrics", 61 | Short: "gh-metrics: provide summary pull request metrics", 62 | Version: Version, 63 | RunE: func(cmd *cobra.Command, args []string) error { 64 | repository, _ := cmd.Flags().GetString("repo") 65 | startDate, _ := cmd.Flags().GetString("start") 66 | endDate, _ := cmd.Flags().GetString("end") 67 | query, _ := cmd.Flags().GetString("query") 68 | onlyWeekdays, _ := cmd.Flags().GetBool("only-weekdays") 69 | csvFormat, _ := cmd.Flags().GetBool("csv") 70 | 71 | repo, err := newGHRepo(repository) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | var workdayFunc cal.WorkdayFn 77 | if onlyWeekdays { 78 | workdayFunc = WorkdayOnlyWeekdays 79 | } else { 80 | workdayFunc = WorkdayAllDays 81 | } 82 | 83 | calendar := &cal.BusinessCalendar{ 84 | WorkdayFunc: workdayFunc, 85 | WorkdayStartFunc: WorkdayStart, 86 | WorkdayEndFunc: WorkdayEnd, 87 | } 88 | 89 | ui := &UI{ 90 | Owner: repo.Owner, 91 | Repository: repo.Name, 92 | Host: repo.Host, 93 | StartDate: startDate, 94 | EndDate: endDate, 95 | Query: query, 96 | CSVFormat: csvFormat, 97 | Calendar: calendar, 98 | } 99 | 100 | cmd.Println(ui.PrintMetrics()) 101 | 102 | return nil 103 | }, 104 | } 105 | 106 | func Execute() { 107 | cobra.CheckErr(RootCmd.Execute()) 108 | } 109 | 110 | func init() { 111 | defaultRepo := "" 112 | currentRepo, _ := gh.CurrentRepository() 113 | if currentRepo != nil { 114 | defaultRepo = fmt.Sprintf("%s/%s", currentRepo.Owner(), currentRepo.Name()) 115 | } 116 | 117 | RootCmd.Flags().StringP("repo", "R", defaultRepo, "target repository in '[HOST/]OWNER/REPO' format (defaults to the current working directory's repository)") 118 | 119 | today := time.Now().UTC() 120 | defaultStart = today.AddDate(0, 0, -DefaultDaysBack).Format(DefaultDateFormat) 121 | defaultEnd = today.Format(DefaultDateFormat) 122 | 123 | RootCmd.Flags().StringP("start", "s", defaultStart, "target start of date range for merged pull requests") 124 | RootCmd.Flags().StringP("end", "e", defaultEnd, "target end of date range for merged pull requests") 125 | RootCmd.Flags().StringP("query", "q", "", "additional query filter for merged pull requests") 126 | 127 | RootCmd.Flags().BoolP("only-weekdays", "w", false, "only include weekdays (M-F) in date range calculations") 128 | RootCmd.Flags().BoolP("csv", "c", false, "print output as CSV") 129 | } 130 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | gh "github.com/cli/go-gh" 11 | "github.com/nbio/st" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/pflag" 14 | "gopkg.in/h2non/gock.v1" 15 | ) 16 | 17 | func ResetSubCommandFlagValues(t *testing.T, root *cobra.Command) { 18 | t.Helper() 19 | 20 | root.Flags().VisitAll(func(f *pflag.Flag) { 21 | if f.Changed { 22 | f.Value.Set(f.DefValue) 23 | f.Changed = false 24 | } 25 | }) 26 | } 27 | 28 | func execute(t *testing.T, args string) string { 29 | t.Helper() 30 | 31 | actual := new(bytes.Buffer) 32 | 33 | RootCmd.SetOut(actual) 34 | RootCmd.SetErr(actual) 35 | ResetSubCommandFlagValues(t, RootCmd) 36 | RootCmd.SetArgs(strings.Split(args, " ")) 37 | RootCmd.Execute() 38 | 39 | return actual.String() 40 | } 41 | 42 | func Test_RootCmd_NoArgs(t *testing.T) { 43 | defer gock.Off() 44 | 45 | currentRepo, err := gh.CurrentRepository() 46 | if err != nil { 47 | t.Error(err) 48 | } 49 | 50 | gock.New("https://api.github.com/graphql"). 51 | Post("/"). 52 | MatchType("json"). 53 | AddMatcher(gqlSearchQueryMatcher(currentRepo.Owner(), currentRepo.Name(), defaultStart, defaultEnd)). 54 | Reply(200). 55 | BodyString(ResponseJSON) 56 | 57 | actual := execute(t, "") 58 | expected := `┌──────┬─────────┬───────────┬───────────┬───────────────┬──────────────────────┬──────────┬──────────────┬───────────────────┬──────────────────────┬─────────────────────────┐ 59 | │ PR │ COMMITS │ ADDITIONS │ DELETIONS │ CHANGED FILES │ TIME TO FIRST REVIEW │ COMMENTS │ PARTICIPANTS │ FEATURE LEAD TIME │ FIRST TO LAST REVIEW │ FIRST APPROVAL TO MERGE │ 60 | ├──────┼─────────┼───────────┼───────────┼───────────────┼──────────────────────┼──────────┼──────────────┼───────────────────┼──────────────────────┼─────────────────────────┤ 61 | │ 5339 │ 1 │ 6 │ 3 │ 1 │ 155h26m │ 0 │ 3 │ 1h12m │ 24h0m │ 22h51m │ 62 | │ 5340 │ 1 │ 12 │ 6 │ 2 │ 155h26m │ 0 │ 3 │ 1h12m │ 24h0m │ 22h51m │ 63 | └──────┴─────────┴───────────┴───────────┴───────────────┴──────────────────────┴──────────┴──────────────┴───────────────────┴──────────────────────┴─────────────────────────┘` 64 | 65 | st.Assert(t, strings.Contains(actual, expected), true) 66 | } 67 | 68 | func Test_RootCmd_Version(t *testing.T) { 69 | actual := execute(t, "--version") 70 | expected := fmt.Sprintf("gh-metrics version %s", Version) 71 | 72 | st.Assert(t, strings.Contains(actual, expected), true) 73 | } 74 | 75 | func Test_RootCmd_OnlyRepo(t *testing.T) { 76 | actual := execute(t, "--repo=cli") 77 | expected := "invalid repository name" 78 | 79 | st.Assert(t, strings.Contains(actual, expected), true) 80 | } 81 | 82 | func Test_WorkdayOnlyWeekdays(t *testing.T) { 83 | friday := time.Date(2022, 4, 1, 0, 0, 0, 0, time.UTC) 84 | saturday := time.Date(2022, 4, 2, 0, 0, 0, 0, time.UTC) 85 | sunday := time.Date(2022, 4, 3, 0, 0, 0, 0, time.UTC) 86 | 87 | st.Assert(t, WorkdayOnlyWeekdays(friday), true) 88 | st.Assert(t, WorkdayOnlyWeekdays(saturday), false) 89 | st.Assert(t, WorkdayOnlyWeekdays(sunday), false) 90 | } 91 | 92 | func Test_WorkdayAllDays(t *testing.T) { 93 | friday := time.Date(2022, 4, 1, 0, 0, 0, 0, time.UTC) 94 | saturday := time.Date(2022, 4, 2, 0, 0, 0, 0, time.UTC) 95 | sunday := time.Date(2022, 4, 3, 0, 0, 0, 0, time.UTC) 96 | 97 | st.Assert(t, WorkdayOnlyWeekdays(friday), true) 98 | st.Assert(t, WorkdayAllDays(saturday), true) 99 | st.Assert(t, WorkdayAllDays(sunday), true) 100 | } 101 | -------------------------------------------------------------------------------- /cmd/suite_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "gopkg.in/h2non/gock.v1" 10 | ) 11 | 12 | const ( 13 | ResponseJSON = ` 14 | { 15 | "data": { 16 | "search": { 17 | "pageInfo": { 18 | "hasNextPage": false, 19 | "endCursor": "Y3Vyc29yOjI=" 20 | }, 21 | "nodes": [ 22 | { 23 | "author": { 24 | "login": "Batman" 25 | }, 26 | "additions": 6, 27 | "deletions": 3, 28 | "number": 5339, 29 | "createdAt": "2022-03-21T15:11:09Z", 30 | "changedFiles": 1, 31 | "isDraft": false, 32 | "mergedAt": "2022-03-21T16:22:05Z", 33 | "participants": { 34 | "totalCount": 3 35 | }, 36 | "comments": { 37 | "totalCount": 0 38 | }, 39 | "reviews": { 40 | "nodes": [ 41 | { 42 | "author": { 43 | "login": "Joker" 44 | }, 45 | "createdAt": "2022-03-21T15:12:52Z", 46 | "state": "COMMENTED" 47 | }, 48 | { 49 | "author": { 50 | "login": "Joker" 51 | }, 52 | "createdAt": "2022-03-22T15:12:52Z", 53 | "state": "APPROVED" 54 | } 55 | ] 56 | }, 57 | "commits": { 58 | "totalCount": 1, 59 | "nodes": [ 60 | { 61 | "commit": { 62 | "committedDate": "2022-03-21T15:09:52Z" 63 | } 64 | } 65 | ] 66 | }, 67 | "timelineItems": { 68 | "totalCount": 1, 69 | "nodes": [ 70 | { 71 | "createdAt": "2022-03-15T03:46:20Z" 72 | } 73 | ] 74 | } 75 | }, 76 | { 77 | "author": { 78 | "login": "Batman" 79 | }, 80 | "additions": 12, 81 | "deletions": 6, 82 | "number": 5340, 83 | "createdAt": "2022-03-22T15:11:09Z", 84 | "changedFiles": 2, 85 | "isDraft": false, 86 | "mergedAt": "2022-03-22T16:22:05Z", 87 | "participants": { 88 | "totalCount": 3 89 | }, 90 | "comments": { 91 | "totalCount": 0 92 | }, 93 | "reviews": { 94 | "nodes": [ 95 | { 96 | "author": { 97 | "login": "Joker" 98 | }, 99 | "createdAt": "2022-03-22T15:12:52Z", 100 | "state": "COMMENTED" 101 | }, 102 | { 103 | "author": { 104 | "login": "Joker" 105 | }, 106 | "createdAt": "2022-03-23T15:12:52Z", 107 | "state": "APPROVED" 108 | } 109 | ] 110 | }, 111 | "commits": { 112 | "totalCount": 1, 113 | "nodes": [ 114 | { 115 | "commit": { 116 | "committedDate": "2022-03-22T15:09:52Z" 117 | } 118 | } 119 | ] 120 | }, 121 | "timelineItems": { 122 | "totalCount": 1, 123 | "nodes": [ 124 | { 125 | "createdAt": "2022-03-16T03:46:20Z" 126 | } 127 | ] 128 | } 129 | } 130 | ] 131 | } 132 | } 133 | }` 134 | ) 135 | 136 | type GQLRequest struct { 137 | Variables struct { 138 | Query string 139 | } 140 | } 141 | 142 | func gqlSearchQueryMatcher(owner, repo, start, end string) func(req *http.Request, ereq *gock.Request) (bool, error) { 143 | return func(req *http.Request, ereq *gock.Request) (bool, error) { 144 | var gqlRequest GQLRequest 145 | 146 | var body, err = io.ReadAll(req.Body) 147 | err = json.Unmarshal(body, &gqlRequest) 148 | 149 | return gqlRequest.Variables.Query == fmt.Sprintf("repo:%s/%s type:pr merged:%s..%s", 150 | owner, 151 | repo, 152 | start, 153 | end), err 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /cmd/ui.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | gh "github.com/cli/go-gh" 10 | "github.com/cli/go-gh/pkg/api" 11 | graphql "github.com/cli/shurcooL-graphql" 12 | "github.com/jedib0t/go-pretty/v6/table" 13 | "github.com/rickar/cal/v2" 14 | ) 15 | 16 | const ( 17 | // Default representation of an empty table cell. 18 | DefaultEmptyCell = "--" 19 | // Default number of search results per query. 20 | DefaultResultCount = 100 21 | // Pull request review approved state. 22 | ReviewApprovedState = "APPROVED" 23 | ) 24 | 25 | type UI struct { 26 | Host string 27 | Owner string 28 | Repository string 29 | StartDate string 30 | EndDate string 31 | Query string 32 | CSVFormat bool 33 | Calendar *cal.BusinessCalendar 34 | } 35 | 36 | // subtractTime returns the duration t1 - t2, with respect to the 37 | // configured calendar. 38 | func (ui *UI) subtractTime(t1, t2 time.Time) time.Duration { 39 | return ui.Calendar.WorkHoursInRange(t1, t2) 40 | } 41 | 42 | // formatDuration formats a duration in hours and minutes, rounded 43 | // to the nearest minute. 44 | func formatDuration(d time.Duration, csvFormat bool) string { 45 | roundedDuration := d.Round(time.Minute) 46 | 47 | if csvFormat { 48 | return excelCompatDuration(roundedDuration) 49 | } 50 | 51 | duration := strings.TrimSuffix(roundedDuration.String(), "0s") 52 | 53 | if len(duration) == 0 { 54 | return DefaultEmptyCell 55 | } 56 | 57 | return duration 58 | } 59 | 60 | // excelCompatDuration formats a duration in hours and minutes, for 61 | // Excel compatibility, rounded to the nearest minute. 62 | func excelCompatDuration(d time.Duration) string { 63 | h := d / time.Hour 64 | d -= h * time.Hour 65 | m := d / time.Minute 66 | 67 | return fmt.Sprintf("%02d:%02d", h, m) 68 | } 69 | 70 | // getReadyForReviewOrPrCreatedAt returns when the pull request was 71 | // marked ready for review, or its created date (if it was never in 72 | // a draft state). 73 | func getReadyForReviewOrPrCreatedAt(prCreated string, timelineItems TimelineItems) string { 74 | if timelineItems.TotalCount == 0 { 75 | return prCreated 76 | } 77 | 78 | return timelineItems.Nodes[0].ReadyForReviewEvent.CreatedAt 79 | } 80 | 81 | // getTimeToFirstReview returns the time to first review, in hours and 82 | // minutes, for a given PR. 83 | // 84 | // timeToFirstReview = (readyForReviewAt || prCreatedAt) - firstReviewdAt 85 | func (ui *UI) getTimeToFirstReview(author, prCreatedAt string, isDraft bool, timelineItems TimelineItems, reviews Reviews) string { 86 | // The pull request is still in a draft state, because it has not 87 | // yet been marked as ready for review. 88 | if timelineItems.TotalCount == 0 && isDraft { 89 | return DefaultEmptyCell 90 | } 91 | 92 | for _, review := range reviews.Nodes { 93 | if review.Author.Login != author { 94 | readyForReviewOrPrCreatedAt, _ := time.Parse(time.RFC3339, getReadyForReviewOrPrCreatedAt(prCreatedAt, timelineItems)) 95 | firstReviewedAt, _ := time.Parse(time.RFC3339, review.CreatedAt) 96 | 97 | return formatDuration(ui.subtractTime(firstReviewedAt, readyForReviewOrPrCreatedAt), ui.CSVFormat) 98 | } 99 | } 100 | 101 | return DefaultEmptyCell 102 | } 103 | 104 | // getFeatureLeadTime returns the feature lead time, in hours and minutes, 105 | // for a given PR. 106 | // 107 | // featureLeadTime = prMergedAt - firstCommitAt 108 | func (ui *UI) getFeatureLeadTime(prMergedAtString string, commits Commits) string { 109 | if len(commits.Nodes) == 0 { 110 | return DefaultEmptyCell 111 | } 112 | 113 | prMergedAt, _ := time.Parse(time.RFC3339, prMergedAtString) 114 | prFirstCommittedAt, _ := time.Parse(time.RFC3339, commits.Nodes[0].Commit.CommittedDate) 115 | 116 | return formatDuration(ui.subtractTime(prMergedAt, prFirstCommittedAt), ui.CSVFormat) 117 | } 118 | 119 | // getFirstReviewToLastReview returns the first review to last approving review time, in 120 | // hours and minutes, for a given PR. 121 | // 122 | // firstReviewToLastReview = lastReviewedAt - firstReviewedAt 123 | func (ui *UI) getFirstReviewToLastReview(login string, reviews Reviews) string { 124 | var nonAuthorReviews ReviewNodes 125 | for _, review := range reviews.Nodes { 126 | if review.Author.Login != login { 127 | nonAuthorReviews = append(nonAuthorReviews, review) 128 | } 129 | } 130 | 131 | if len(nonAuthorReviews) == 0 { 132 | return DefaultEmptyCell 133 | } 134 | 135 | firstReviewedAt, _ := time.Parse(time.RFC3339, nonAuthorReviews[0].CreatedAt) 136 | 137 | // Iterate in reverse order to get the last approving review 138 | for i := len(nonAuthorReviews) - 1; i >= 0; i-- { 139 | if nonAuthorReviews[i].State == ReviewApprovedState { 140 | lastReviewedAt, _ := time.Parse(time.RFC3339, nonAuthorReviews[i].CreatedAt) 141 | return formatDuration(ui.subtractTime(lastReviewedAt, firstReviewedAt), ui.CSVFormat) 142 | } 143 | } 144 | 145 | return DefaultEmptyCell 146 | } 147 | 148 | // getFirstApprovalToMerge returns the first approval review to merge time, in 149 | // hours and minutes, for a given PR. 150 | // 151 | // firstApprovalToMerge = prMergedAt - firstApprovedAt 152 | func (ui *UI) getFirstApprovalToMerge(author, prMergedAtString string, reviews Reviews) string { 153 | for _, review := range reviews.Nodes { 154 | if review.Author.Login != author && review.State == ReviewApprovedState { 155 | prMergedAt, _ := time.Parse(time.RFC3339, prMergedAtString) 156 | firstApprovedAt, _ := time.Parse(time.RFC3339, review.CreatedAt) 157 | 158 | return formatDuration(ui.subtractTime(prMergedAt, firstApprovedAt), ui.CSVFormat) 159 | } 160 | } 161 | 162 | return DefaultEmptyCell 163 | } 164 | 165 | // PrintMetrics returns a string representation of the metrics summary for 166 | // a set of pull requests determined by the supplied date range, using 167 | // DefaultResultCount. 168 | func (ui *UI) PrintMetrics() string { 169 | return ui.printMetricsImpl(DefaultResultCount) 170 | } 171 | 172 | // printMetricsImpl returns a string representation of the metrics summary 173 | // for a set of pull requests determined by the supplied date range. 174 | func (ui *UI) printMetricsImpl(defaultResultCount int) string { 175 | client, err := gh.GQLClient( 176 | &api.ClientOptions{ 177 | Host: ui.Host, 178 | EnableCache: true, 179 | CacheTTL: 15 * time.Minute, 180 | Timeout: 5 * time.Second, 181 | }, 182 | ) 183 | if err != nil { 184 | log.Fatal("To authenticate, please run `gh auth login`.") 185 | } 186 | 187 | var gqlQuery MetricsGQLQuery 188 | var gqlQueryVariables map[string]interface{} = map[string]interface{}{ 189 | "query": graphql.String( 190 | strings.TrimSpace(fmt.Sprintf("repo:%s/%s type:pr merged:%s..%s %s", 191 | ui.Owner, 192 | ui.Repository, 193 | ui.StartDate, 194 | ui.EndDate, 195 | ui.Query))), 196 | "resultCount": graphql.Int(defaultResultCount), 197 | "afterCursor": (*graphql.String)(nil), 198 | } 199 | 200 | err = client.Query("PullRequests", &gqlQuery, gqlQueryVariables) 201 | if err != nil { 202 | log.Fatal(err) 203 | } 204 | 205 | t := table.NewWriter() 206 | t.SetStyle(table.StyleLight) 207 | 208 | t.AppendHeader(table.Row{ 209 | "PR", 210 | "Commits", 211 | "Additions", 212 | "Deletions", 213 | "Changed Files", 214 | "Time to First Review", 215 | "Comments", 216 | "Participants", 217 | "Feature Lead Time", 218 | "First to Last Review", 219 | "First Approval to Merge", 220 | }) 221 | 222 | for { 223 | for _, node := range gqlQuery.Search.Nodes { 224 | t.AppendRow(table.Row{ 225 | node.PullRequest.Number, 226 | node.PullRequest.Commits.TotalCount, 227 | node.PullRequest.Additions, 228 | node.PullRequest.Deletions, 229 | node.PullRequest.ChangedFiles, 230 | ui.getTimeToFirstReview( 231 | node.PullRequest.Author.Login, 232 | node.PullRequest.CreatedAt, 233 | node.PullRequest.IsDraft, 234 | node.PullRequest.TimelineItems, 235 | node.PullRequest.Reviews, 236 | ), 237 | node.PullRequest.Comments.TotalCount, 238 | node.PullRequest.Participants.TotalCount, 239 | ui.getFeatureLeadTime( 240 | node.PullRequest.MergedAt, 241 | node.PullRequest.Commits, 242 | ), 243 | ui.getFirstReviewToLastReview( 244 | node.PullRequest.Author.Login, 245 | node.PullRequest.Reviews, 246 | ), 247 | ui.getFirstApprovalToMerge( 248 | node.PullRequest.Author.Login, 249 | node.PullRequest.MergedAt, 250 | node.PullRequest.Reviews, 251 | ), 252 | }) 253 | } 254 | 255 | if gqlQuery.Search.PageInfo.HasNextPage { 256 | gqlQueryVariables["afterCursor"] = graphql.String(gqlQuery.Search.PageInfo.EndCursor) 257 | err = client.Query("PullRequests", &gqlQuery, gqlQueryVariables) 258 | if err != nil { 259 | log.Fatal(err) 260 | } 261 | } else { 262 | break 263 | } 264 | } 265 | 266 | if ui.CSVFormat { 267 | return t.RenderCSV() 268 | } 269 | 270 | return t.Render() 271 | } 272 | -------------------------------------------------------------------------------- /cmd/ui_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/nbio/st" 13 | "github.com/rickar/cal/v2" 14 | "gopkg.in/h2non/gock.v1" 15 | ) 16 | 17 | const ( 18 | Owner = "testOwner" 19 | Repository = "testRepo" 20 | StartDate = "2022-03-18" 21 | EndDate = "2022-03-28" 22 | Query = "author:Batman" 23 | ) 24 | 25 | func gqlSearchQueryWithFilterMatcher(req *http.Request, ereq *gock.Request) (bool, error) { 26 | var gqlRequest GQLRequest 27 | 28 | var body, err = io.ReadAll(req.Body) 29 | err = json.Unmarshal(body, &gqlRequest) 30 | 31 | return gqlRequest.Variables.Query == fmt.Sprintf("repo:%s/%s type:pr merged:%s..%s %s", 32 | Owner, 33 | Repository, 34 | StartDate, 35 | EndDate, 36 | Query), err 37 | } 38 | 39 | func Test_SearchQuery(t *testing.T) { 40 | defer gock.Off() 41 | 42 | gock.New("https://api.github.com/graphql"). 43 | Post("/"). 44 | MatchType("json"). 45 | AddMatcher(gqlSearchQueryMatcher(Owner, Repository, StartDate, EndDate)). 46 | Reply(200). 47 | BodyString(ResponseJSON) 48 | 49 | ui := &UI{ 50 | Owner: Owner, 51 | Repository: Repository, 52 | StartDate: StartDate, 53 | EndDate: EndDate, 54 | CSVFormat: false, 55 | Calendar: cal.NewBusinessCalendar(), 56 | } 57 | 58 | have := ui.PrintMetrics() 59 | 60 | st.Assert(t, strings.Contains(have, "5339"), true) 61 | st.Assert(t, strings.Contains(have, "5340"), true) 62 | } 63 | 64 | func Test_SearchQuery_WithCSV(t *testing.T) { 65 | defer gock.Off() 66 | 67 | gock.New("https://api.github.com/graphql"). 68 | Post("/"). 69 | MatchType("json"). 70 | AddMatcher(gqlSearchQueryMatcher(Owner, Repository, StartDate, EndDate)). 71 | Reply(200). 72 | BodyString(ResponseJSON) 73 | 74 | ui := &UI{ 75 | Owner: Owner, 76 | Repository: Repository, 77 | StartDate: StartDate, 78 | EndDate: EndDate, 79 | CSVFormat: true, 80 | Calendar: cal.NewBusinessCalendar(), 81 | } 82 | 83 | have := ui.PrintMetrics() 84 | 85 | st.Assert(t, strings.Contains(have, "5339,1,6,3,1,38:13,0,3,01:12,08:00,06:51"), true) 86 | st.Assert(t, strings.Contains(have, "5340,1,12,6,2,38:13,0,3,01:12,08:00,06:51"), true) 87 | } 88 | 89 | func Test_SearchQuery_WithPagination(t *testing.T) { 90 | defer gock.Off() 91 | 92 | responseJSONWithPagination := strings.ReplaceAll( 93 | strings.Clone(ResponseJSON), 94 | "\"hasNextPage\": false,", 95 | "\"hasNextPage\": true,", 96 | ) 97 | 98 | gock.New("https://api.github.com/graphql"). 99 | Post("/"). 100 | MatchType("json"). 101 | AddMatcher(gqlSearchQueryMatcher(Owner, Repository, StartDate, EndDate)). 102 | Reply(200). 103 | BodyString(responseJSONWithPagination) 104 | 105 | gock.New("https://api.github.com/graphql"). 106 | Post("/"). 107 | MatchType("json"). 108 | AddMatcher(gqlSearchQueryMatcher(Owner, Repository, StartDate, EndDate)). 109 | Reply(200). 110 | BodyString(ResponseJSON) 111 | 112 | ui := &UI{ 113 | Owner: Owner, 114 | Repository: Repository, 115 | StartDate: StartDate, 116 | EndDate: EndDate, 117 | CSVFormat: true, 118 | Calendar: cal.NewBusinessCalendar(), 119 | } 120 | 121 | have := ui.printMetricsImpl(1) 122 | 123 | st.Assert(t, strings.Contains(have, "5339,1,6,3,1,38:13,0,3,01:12,08:00,06:51"), true) 124 | st.Assert(t, strings.Contains(have, "5340,1,12,6,2,38:13,0,3,01:12,08:00,06:51"), true) 125 | } 126 | 127 | func Test_SearchQuery_WithQueryFilter(t *testing.T) { 128 | defer gock.Off() 129 | 130 | gock.New("https://api.github.com/graphql"). 131 | Post("/"). 132 | MatchType("json"). 133 | AddMatcher(gqlSearchQueryWithFilterMatcher). 134 | Reply(200). 135 | BodyString(ResponseJSON) 136 | 137 | ui := &UI{ 138 | Owner: Owner, 139 | Repository: Repository, 140 | StartDate: StartDate, 141 | EndDate: EndDate, 142 | Query: Query, 143 | CSVFormat: true, 144 | Calendar: cal.NewBusinessCalendar(), 145 | } 146 | 147 | have := ui.printMetricsImpl(1) 148 | 149 | st.Assert(t, strings.Contains(have, "5339,1,6,3,1,38:13,0,3,01:12,08:00,06:51"), true) 150 | st.Assert(t, strings.Contains(have, "5340,1,12,6,2,38:13,0,3,01:12,08:00,06:51"), true) 151 | } 152 | 153 | func Test_subtractTime_WithinWorkday(t *testing.T) { 154 | start := time.Date(2022, time.Month(3), 21, 15, 11, 9, 0, time.UTC) 155 | end := time.Date(2022, time.Month(3), 21, 15, 12, 52, 0, time.UTC) 156 | 157 | ui := &UI{ 158 | Calendar: cal.NewBusinessCalendar(), 159 | } 160 | 161 | st.Assert(t, ui.subtractTime(end, start).String(), "1m43s") 162 | st.Assert(t, ui.subtractTime(end, start).String(), "1m43s") 163 | } 164 | 165 | func Test_subtractTime_SpanningWeekend(t *testing.T) { 166 | start := time.Date(2022, time.Month(3), 25, 17, 0, 0, 0, time.UTC) 167 | end := time.Date(2022, time.Month(3), 28, 0, 0, 0, 0, time.UTC) 168 | 169 | uiWithWeekends := &UI{ 170 | Calendar: &cal.BusinessCalendar{ 171 | WorkdayFunc: WorkdayAllDays, 172 | WorkdayStartFunc: WorkdayStart, 173 | WorkdayEndFunc: WorkdayEnd, 174 | }, 175 | } 176 | st.Assert(t, uiWithWeekends.subtractTime(end, start).String(), "54h59m57s") 177 | 178 | uiWithoutWeekends := &UI{ 179 | Calendar: &cal.BusinessCalendar{ 180 | WorkdayFunc: WorkdayOnlyWeekdays, 181 | WorkdayStartFunc: WorkdayStart, 182 | WorkdayEndFunc: WorkdayEnd, 183 | }, 184 | } 185 | st.Assert(t, uiWithoutWeekends.subtractTime(end, start).String(), "6h59m59s") 186 | } 187 | 188 | func Test_formatDuration_LessThanMinute(t *testing.T) { 189 | st.Assert(t, formatDuration(time.Second*5, false), DefaultEmptyCell) 190 | } 191 | 192 | func Test_formatDuration_LessThanMinuteWithCSV(t *testing.T) { 193 | st.Assert(t, formatDuration(time.Second*5, true), "00:00") 194 | } 195 | 196 | func Test_formatDuration_MoreThanMinute(t *testing.T) { 197 | st.Assert(t, formatDuration(time.Minute*5, false), "5m") 198 | } 199 | 200 | func Test_formatDuration_MoreThanMinuteWithCSV(t *testing.T) { 201 | st.Assert(t, formatDuration(time.Minute*5, true), "00:05") 202 | } 203 | 204 | func Test_getReadyForReviewOrPrCreatedAt_prCreatedAt(t *testing.T) { 205 | st.Assert(t, getReadyForReviewOrPrCreatedAt("2022-03-21T15:11:09Z", TimelineItems{ 206 | TotalCount: 0, 207 | }) == "2022-03-21T15:11:09Z", true) 208 | } 209 | 210 | func Test_getReadyForReviewOrPrCreatedAt_readyForReviewAt(t *testing.T) { 211 | st.Assert(t, getReadyForReviewOrPrCreatedAt("2022-03-21T15:11:09Z", TimelineItems{ 212 | TotalCount: 1, 213 | Nodes: TimelineItemNodes{ 214 | {ReadyForReviewEvent{CreatedAt: "2022-03-22T15:11:09Z"}}, 215 | }}) == "2022-03-22T15:11:09Z", true) 216 | } 217 | 218 | func Test_getTimeToFirstReview(t *testing.T) { 219 | var timelineItems = TimelineItems{ 220 | TotalCount: 1, 221 | Nodes: TimelineItemNodes{ 222 | {ReadyForReviewEvent{CreatedAt: "2022-03-21T15:11:09Z"}}, 223 | }, 224 | } 225 | var reviews = Reviews{ 226 | Nodes: ReviewNodes{ 227 | { 228 | Author: Author{Login: "Batman"}, 229 | CreatedAt: "2022-03-19T15:00:09Z", 230 | State: "COMMENTED", 231 | }, 232 | { 233 | Author: Author{Login: "Joker"}, 234 | CreatedAt: "2022-03-20T15:11:09Z", 235 | State: "APPROVED", 236 | }, 237 | }, 238 | } 239 | 240 | uiWithWeekends := &UI{ 241 | Calendar: &cal.BusinessCalendar{ 242 | WorkdayFunc: WorkdayAllDays, 243 | WorkdayStartFunc: WorkdayStart, 244 | WorkdayEndFunc: WorkdayEnd, 245 | }, 246 | } 247 | st.Assert(t, uiWithWeekends.getTimeToFirstReview("Batman", "", false, timelineItems, reviews), "24h0m") 248 | 249 | uiWithoutWeekends := &UI{ 250 | Calendar: &cal.BusinessCalendar{ 251 | WorkdayFunc: WorkdayOnlyWeekdays, 252 | WorkdayStartFunc: WorkdayStart, 253 | WorkdayEndFunc: WorkdayEnd, 254 | }, 255 | } 256 | st.Assert(t, uiWithoutWeekends.getTimeToFirstReview("Batman", "", false, timelineItems, reviews), "15h11m") 257 | } 258 | 259 | func Test_getTimeToFirstReview_Draft(t *testing.T) { 260 | var timelineItems = TimelineItems{ 261 | Nodes: TimelineItemNodes{}, 262 | } 263 | var reviews = Reviews{ 264 | Nodes: ReviewNodes{ 265 | { 266 | Author: Author{Login: "Joker"}, 267 | CreatedAt: "2022-03-22T15:11:09Z", 268 | State: "COMMENTED", 269 | }, 270 | }, 271 | } 272 | 273 | ui := &UI{} 274 | st.Assert(t, ui.getTimeToFirstReview("Batman", "", true, timelineItems, reviews), "--") 275 | } 276 | 277 | func Test_getTimeToFirstReview_NoReviews(t *testing.T) { 278 | var timelineItems = TimelineItems{ 279 | Nodes: TimelineItemNodes{ 280 | {ReadyForReviewEvent{CreatedAt: "2022-03-21T15:11:09Z"}}, 281 | }, 282 | } 283 | var reviews = Reviews{ 284 | Nodes: ReviewNodes{}, 285 | } 286 | 287 | ui := &UI{} 288 | st.Assert(t, ui.getTimeToFirstReview("Batman", "", false, timelineItems, reviews), "--") 289 | } 290 | 291 | func Test_getFeatureLeadTime(t *testing.T) { 292 | var commits = Commits{ 293 | TotalCount: 1, 294 | Nodes: CommitNodes{ 295 | {Commit{CommittedDate: "2022-03-20T15:11:09Z"}}, 296 | }, 297 | } 298 | 299 | uiWithWeekends := &UI{ 300 | Calendar: &cal.BusinessCalendar{ 301 | WorkdayFunc: WorkdayAllDays, 302 | WorkdayStartFunc: WorkdayStart, 303 | WorkdayEndFunc: WorkdayEnd, 304 | }, 305 | } 306 | st.Assert(t, uiWithWeekends.getFeatureLeadTime("2022-03-21T15:11:09Z", commits), "24h0m") 307 | 308 | uiWithoutWeekends := &UI{ 309 | Calendar: &cal.BusinessCalendar{ 310 | WorkdayFunc: WorkdayOnlyWeekdays, 311 | WorkdayStartFunc: WorkdayStart, 312 | WorkdayEndFunc: WorkdayEnd, 313 | }, 314 | } 315 | st.Assert(t, uiWithoutWeekends.getFeatureLeadTime("2022-03-21T15:11:09Z", commits), "15h11m") 316 | } 317 | 318 | func Test_getFeatureLeadTime_NoCommits(t *testing.T) { 319 | var commits = Commits{ 320 | TotalCount: 0, 321 | Nodes: CommitNodes{}, 322 | } 323 | 324 | ui := &UI{} 325 | st.Assert(t, ui.getFeatureLeadTime("2022-03-21T15:11:09Z", commits), "--") 326 | } 327 | 328 | func Test_getFirstReviewToLastReview(t *testing.T) { 329 | var reviews = Reviews{ 330 | Nodes: ReviewNodes{ 331 | { 332 | Author: Author{ 333 | Login: "Batman", 334 | }, 335 | CreatedAt: "2022-04-06T15:11:09Z", 336 | State: "COMMENTED", 337 | }, 338 | { 339 | Author: Author{ 340 | Login: "Joker", 341 | }, 342 | CreatedAt: "2022-04-06T16:11:09Z", 343 | State: "CHANGES_REQUESTED", 344 | }, 345 | { 346 | Author: Author{ 347 | Login: "Joker", 348 | }, 349 | CreatedAt: "2022-04-06T17:11:09Z", 350 | State: "APPROVED", 351 | }, 352 | }, 353 | } 354 | 355 | uiWithWeekends := &UI{ 356 | Calendar: &cal.BusinessCalendar{ 357 | WorkdayFunc: WorkdayAllDays, 358 | WorkdayStartFunc: WorkdayStart, 359 | WorkdayEndFunc: WorkdayEnd, 360 | }, 361 | } 362 | 363 | st.Assert(t, uiWithWeekends.getFirstReviewToLastReview("Batman", reviews), "1h0m") 364 | } 365 | 366 | func Test_getFirstReviewToLastReview_AuthorReviewLast(t *testing.T) { 367 | var reviews = Reviews{ 368 | Nodes: ReviewNodes{ 369 | { 370 | Author: Author{ 371 | Login: "Joker", 372 | }, 373 | CreatedAt: "2022-04-06T16:11:09Z", 374 | State: "CHANGES_REQUESTED", 375 | }, 376 | { 377 | Author: Author{ 378 | Login: "Joker", 379 | }, 380 | CreatedAt: "2022-04-06T17:11:09Z", 381 | State: "APPROVED", 382 | }, 383 | { 384 | Author: Author{ 385 | Login: "Batman", 386 | }, 387 | CreatedAt: "2022-04-06T18:11:09Z", 388 | State: "COMMENTED", 389 | }, 390 | }, 391 | } 392 | 393 | uiWithWeekends := &UI{ 394 | Calendar: &cal.BusinessCalendar{ 395 | WorkdayFunc: WorkdayAllDays, 396 | WorkdayStartFunc: WorkdayStart, 397 | WorkdayEndFunc: WorkdayEnd, 398 | }, 399 | } 400 | 401 | st.Assert(t, uiWithWeekends.getFirstReviewToLastReview("Batman", reviews), "1h0m") 402 | } 403 | 404 | func Test_getFirstReviewToLastReview_ReviewerReviewCommentLast(t *testing.T) { 405 | var reviews = Reviews{ 406 | Nodes: ReviewNodes{ 407 | { 408 | Author: Author{ 409 | Login: "Joker", 410 | }, 411 | CreatedAt: "2022-04-06T16:11:09Z", 412 | State: "CHANGES_REQUESTED", 413 | }, 414 | { 415 | Author: Author{ 416 | Login: "Joker", 417 | }, 418 | CreatedAt: "2022-04-06T17:11:09Z", 419 | State: "APPROVED", 420 | }, 421 | { 422 | Author: Author{ 423 | Login: "Joker", 424 | }, 425 | CreatedAt: "2022-04-06T18:11:09Z", 426 | State: "COMMENTED", 427 | }, 428 | }, 429 | } 430 | 431 | uiWithWeekends := &UI{ 432 | Calendar: &cal.BusinessCalendar{ 433 | WorkdayFunc: WorkdayAllDays, 434 | WorkdayStartFunc: WorkdayStart, 435 | WorkdayEndFunc: WorkdayEnd, 436 | }, 437 | } 438 | 439 | st.Assert(t, uiWithWeekends.getFirstReviewToLastReview("Batman", reviews), "1h0m") 440 | } 441 | 442 | func Test_getFirstReviewToLastReview_OnlyAuthorReview(t *testing.T) { 443 | var reviews = Reviews{ 444 | Nodes: ReviewNodes{ 445 | { 446 | Author: Author{ 447 | Login: "Batman", 448 | }, 449 | CreatedAt: "2022-04-06T15:11:09Z", 450 | State: "COMMENTED", 451 | }, 452 | }, 453 | } 454 | 455 | uiWithWeekends := &UI{ 456 | Calendar: &cal.BusinessCalendar{ 457 | WorkdayFunc: WorkdayAllDays, 458 | WorkdayStartFunc: WorkdayStart, 459 | WorkdayEndFunc: WorkdayEnd, 460 | }, 461 | } 462 | 463 | st.Assert(t, uiWithWeekends.getFirstReviewToLastReview("Batman", reviews), DefaultEmptyCell) 464 | } 465 | 466 | func Test_getFirstReviewToLastReview_NoApprovals(t *testing.T) { 467 | var reviews = Reviews{ 468 | Nodes: ReviewNodes{ 469 | { 470 | Author: Author{ 471 | Login: "Joker", 472 | }, 473 | CreatedAt: "2022-04-06T15:11:09Z", 474 | State: "COMMENTED", 475 | }, 476 | }, 477 | } 478 | 479 | uiWithWeekends := &UI{ 480 | Calendar: &cal.BusinessCalendar{ 481 | WorkdayFunc: WorkdayAllDays, 482 | WorkdayStartFunc: WorkdayStart, 483 | WorkdayEndFunc: WorkdayEnd, 484 | }, 485 | } 486 | 487 | st.Assert(t, uiWithWeekends.getFirstReviewToLastReview("Batman", reviews), DefaultEmptyCell) 488 | } 489 | 490 | func Test_getFirstApprovalToMerge(t *testing.T) { 491 | var reviews = Reviews{ 492 | Nodes: ReviewNodes{ 493 | { 494 | Author: Author{Login: "Batman"}, 495 | CreatedAt: "2022-03-19T15:00:09Z", 496 | State: "COMMENTED", 497 | }, 498 | { 499 | Author: Author{Login: "Joker"}, 500 | CreatedAt: "2022-03-20T15:11:09Z", 501 | State: "APPROVED", 502 | }, 503 | }, 504 | } 505 | 506 | uiWithWeekends := &UI{ 507 | Calendar: &cal.BusinessCalendar{ 508 | WorkdayFunc: WorkdayAllDays, 509 | WorkdayStartFunc: WorkdayStart, 510 | WorkdayEndFunc: WorkdayEnd, 511 | }, 512 | } 513 | st.Assert(t, uiWithWeekends.getFirstApprovalToMerge("Batman", "2022-03-21T15:11:09Z", reviews), "24h0m") 514 | 515 | uiWithoutWeekends := &UI{ 516 | Calendar: &cal.BusinessCalendar{ 517 | WorkdayFunc: WorkdayOnlyWeekdays, 518 | WorkdayStartFunc: WorkdayStart, 519 | WorkdayEndFunc: WorkdayEnd, 520 | }, 521 | } 522 | st.Assert(t, uiWithoutWeekends.getFirstApprovalToMerge("Batman", "2022-03-21T15:11:09Z", reviews), "15h11m") 523 | } 524 | 525 | func Test_getFirstApprovalToMerge_NoReviews(t *testing.T) { 526 | var reviews = Reviews{ 527 | Nodes: ReviewNodes{}, 528 | } 529 | 530 | ui := &UI{} 531 | st.Assert(t, ui.getFirstApprovalToMerge("Batman", "2022-03-21T15:11:09Z", reviews), "--") 532 | } 533 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hectcastro/gh-metrics 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/cli/go-gh v1.2.1 7 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 8 | github.com/rickar/cal/v2 v2.1.23 9 | github.com/spf13/pflag v1.0.6 10 | gopkg.in/h2non/gock.v1 v1.1.2 11 | ) 12 | 13 | require ( 14 | github.com/aymanbagabas/go-osc52 v1.2.1 // indirect 15 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/kr/text v0.2.0 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-isatty v0.0.17 // indirect 20 | github.com/mattn/go-runewidth v0.0.16 // indirect 21 | github.com/muesli/termenv v0.14.0 // indirect 22 | github.com/rivo/uniseg v0.4.7 // indirect 23 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect 24 | golang.org/x/sys v0.30.0 // indirect 25 | golang.org/x/term v0.29.0 // indirect 26 | golang.org/x/text v0.22.0 // indirect 27 | ) 28 | 29 | require ( 30 | github.com/cli/safeexec v1.0.1 // indirect 31 | github.com/cli/shurcooL-graphql v0.0.4 32 | github.com/henvic/httpretty v0.1.0 // indirect 33 | github.com/jedib0t/go-pretty/v6 v6.6.7 34 | github.com/spf13/cobra v1.9.1 35 | gopkg.in/yaml.v3 v3.0.1 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= 4 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 5 | github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o= 6 | github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM= 7 | github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= 8 | github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 9 | github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= 10 | github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 12 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 16 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 17 | github.com/henvic/httpretty v0.1.0 h1:Htk66UUEbXTD4JR0qJZaw8YAMKw+9I24ZZOnDe/ti+E= 18 | github.com/henvic/httpretty v0.1.0/go.mod h1:ViEsly7wgdugYtymX54pYp6Vv2wqZmNHayJ6q8tlKCc= 19 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 20 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 21 | github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= 22 | github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= 23 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 24 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 28 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 29 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 30 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 32 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 33 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 34 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 35 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 36 | github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0= 37 | github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= 38 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 39 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/rickar/cal/v2 v2.1.23 h1:uTYRCpZwMKIBIyNhjf+24ukdt3n8JegMZCst4om79FA= 43 | github.com/rickar/cal/v2 v2.1.23/go.mod h1:/fdlMcx7GjPlIBibMzOM9gMvDBsrK+mOtRXdTzUqV/A= 44 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 45 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 46 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 47 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 48 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 49 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 50 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 51 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 52 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 53 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 54 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= 55 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= 56 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 58 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 59 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 60 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 61 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 62 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 63 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 64 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 65 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 66 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 68 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 73 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 74 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 75 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 76 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 77 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 78 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 79 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 80 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 81 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 82 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 83 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 84 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 85 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 86 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 87 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 88 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 89 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 90 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 93 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 | gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= 95 | gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= 96 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 97 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/hectcastro/gh-metrics/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /scripts/gofmtcheck: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | echo "==> Checking that code complies with gofmt requirements..." 6 | 7 | gofmt_files=$(gofmt -l $(find . -name '*.go' | grep -v vendor)) 8 | if [[ -n ${gofmt_files} ]]; then 9 | echo 'gofmt needs running on the following files:' 10 | echo "${gofmt_files}" 11 | echo "You can use the command: \`make fmt\` to reformat code." 12 | exit 1 13 | fi 14 | 15 | exit 0 16 | --------------------------------------------------------------------------------