├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── ghtool ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── github.graphql ├── hurl │ └── github-device-flow ├── justfile └── src │ ├── bin │ └── main.rs │ ├── cache.rs │ ├── cli.rs │ ├── commands │ ├── auth │ │ ├── login.rs │ │ ├── logout.rs │ │ └── mod.rs │ ├── build │ │ ├── mod.rs │ │ └── tsc.rs │ ├── command.rs │ ├── lint │ │ ├── eslint.rs │ │ └── mod.rs │ ├── mod.rs │ └── test │ │ ├── jest.rs │ │ └── mod.rs │ ├── git.rs │ ├── github │ ├── auth_client.rs │ ├── client.rs │ ├── current_user.graphql │ ├── current_user.rs │ ├── mod.rs │ ├── pull_request_for_branch.graphql │ ├── pull_request_for_branch.rs │ ├── pull_request_status_checks.graphql │ ├── pull_request_status_checks.rs │ ├── types.rs │ └── wait_for_pr_checks.rs │ ├── lib.rs │ ├── repo_config.rs │ ├── setup.rs │ ├── spinner.rs │ ├── term.rs │ └── token_store.rs ├── ghtool_devtools ├── Cargo.toml └── src │ └── bin │ └── parse_jest_log.rs └── github_schema ├── Cargo.toml ├── github.graphql └── src └── lib.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Build 19 | run: cargo build --verbose 20 | - name: Run tests 21 | run: cargo test --verbose 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "ghtool", 4 | "ghtool_devtools", 5 | "github_schema" 6 | ] 7 | resolver = "2" 8 | 9 | default-members = [ 10 | "ghtool" 11 | ] 12 | 13 | [profile.dev] 14 | debug = 0 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghtool 2 | 3 | [![Crates.io][crates-badge]][crates-url] 4 | ![rust][build-badge] 5 | 6 | `ghtool` is a CLI tool designed to simplify interaction with GitHub Actions, 7 | particularly when it comes to checking for test failures, linting issues, and 8 | build errors. It provides aggregated view of these issues without having to 9 | manually go through logs or GitHub's user interface. This is especially helpful 10 | in large codebases where tests are distributed across multiple jobs. 11 | 12 | 13 | 14 | See the [demo](#demo). 15 | 16 | ## Features 17 | 18 | - List failing tests across all jobs, currently only for Jest 19 | - List linting issues across all jobs, currently only for ESLint 20 | - List build errors across all jobs, currently only for TypeScript 21 | - With `all` subcommand, wait for checks to complete and list test, lint or build errors 22 | 23 | ## Installation 24 | 25 | Rust toolchain is required. Install it from [rustup.rs](https://rustup.rs/). 26 | 27 | ```sh 28 | cargo install ghtool 29 | ``` 30 | 31 | ## Setup 32 | 33 | `ghtool` requires a GitHub access token to access the GitHub API. The token is 34 | stored in the system keychain. The token is strictly used by `ghtool` for 35 | accessing the GitHub API to accomplish the tasks it's designed for. The token 36 | is not used for any other purpose. 37 | 38 | To authenticate `ghtool` with GitHub API's OAuth flow, run: 39 | 40 | ```sh 41 | ght login 42 | ``` 43 | 44 | Alternatively, you may provide a [personal access token][personal-access-token] 45 | with `repo` scope using `--stdin` parameter. 46 | 47 | ```sh 48 | pbpaste | ght login --stdin 49 | ``` 50 | 51 | For details on why the `repo` scope is needed: [On required permissions](#on-required-permissions) 52 | 53 | ## Usage 54 | 55 | The tool is installed as executable `ght` for ease of use. 56 | 57 | The tool is intended to be run in a repository, as it uses the current working 58 | directory to determine the repository to operate on. The current branch is used 59 | to determine which pull request to query. 60 | 61 | ``` 62 | Usage: ght [OPTIONS] [COMMAND] 63 | 64 | Commands: 65 | test Get the failing tests for the current branch's pull request's checks 66 | lint Get lint issues for the current branch's pull request's checks 67 | build Get build issues for the current branch's pull request's checks 68 | all Wait for checks to complete and run all test, lint and build together 69 | login Authenticate ghtool with GitHub API 70 | logout Deauthenticate ghtool with GitHub API 71 | help Print this message or the help of the given subcommand(s) 72 | 73 | Options: 74 | -v, --verbose Print verbose output 75 | -b, --branch Target branch; defaults to current branch 76 | -h, --help Print help 77 | -V, --version Print version 78 | ``` 79 | 80 | ## Configuration 81 | 82 | The `.ghtool.toml` configuration file in your repository root is required. The 83 | file consists of three optional sections: `test`, `lint`, and `build`. Each 84 | section is used to configure the corresponding functionality of `ghtool`. 85 | 86 | ### `test` 87 | 88 | - `job_pattern`: Regular expression to match test job names. 89 | - `tool`: Test runner used in tests. Determines how logs are parsed. Only 90 | "jest" is currently supported. 91 | 92 | ### `lint` 93 | 94 | - `job_pattern`: Regular expression to match job names for linting. 95 | - `tool`: Lint tool used in the checks. Determines how logs are parsed. Only 96 | "eslint" is currently supported. 97 | 98 | ### `build` 99 | 100 | - `job_pattern`: Regular expression to match build job names. 101 | - `tool`: Build tool used in matching jobs. Determines how logs are parsed. 102 | Only "tsc" is currently supported. 103 | 104 | ### Example 105 | 106 | Here's an example `.ghtool.toml` file: 107 | 108 | ```toml 109 | [test] 110 | job_pattern = "(Unit|Integration|End-to-end) tests sharded" 111 | tool = "jest" 112 | 113 | [lint] 114 | job_pattern = "Lint" 115 | tool = "eslint" 116 | 117 | [build] 118 | job_pattern = "Typecheck" 119 | tool = "tsc" 120 | ``` 121 | 122 | ## Example usage 123 | 124 | ### Check failing tests 125 | 126 | ``` 127 | % ght test 128 | ┌─────────────────────────────────────────────────────────────────────────────┐ 129 | │ Job: Unit tests sharded (2) │ 130 | │ Url: https://github.com/org/repo/actions/runs/5252627921/jobs/9488888294 │ 131 | └─────────────────────────────────────────────────────────────────────────────┘ 132 | FAIL src/components/MyComponent/MyComponent.test.tsx 133 | ● Test suite failed to run 134 | Error: Cannot read property 'foo' of undefined 135 | 136 | 1 | import React from 'react'; 137 | 2 | import { render } from '@testing-library/react'; 138 | > 3 | import MyComponent from './MyComponent'; 139 | | ^ 140 | 4 | 141 | 5 | test('renders learn react link', () => { 142 | 6 | const { getByText } = render(); 143 | 144 | ┌─────────────────────────────────────────────────────────────────────────────┐ 145 | │ Job: Unit tests sharded (3) │ 146 | │ Url: https://github.com/org/repo/actions/runs/5252627921/jobs/9488888295 │ 147 | └─────────────────────────────────────────────────────────────────────────────┘ 148 | FAIL src/components/AnotherComponent/AnotherComponent.test.tsx 149 | ● Test suite failed to run 150 | ... 151 | ``` 152 | 153 | ### Check lint issues 154 | 155 | ``` 156 | % ght lint 157 | ┌─────────────────────────────────────────────────────────────────────────────┐ 158 | │ Job: Lint │ 159 | │ Url: https://github.com/org/repo/actions/runs/5252627921/jobs/9488888294 │ 160 | └─────────────────────────────────────────────────────────────────────────────┘ 161 | @org/module:lint: /path/to/work/directory/src/components/component-directory/subcomponent-file/index.tsx 162 | @org/module:lint: 99:54 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 163 | @org/module:lint: 109:46 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 164 | @org/module:lint: 143:59 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 165 | 166 | @org/module:lint: /path/to/work/directory/src/components/another-component/ComponentTest.spec.tsx 167 | @org/module:lint: 30:33 warning Forbidden non-null assertion @typescript-eslint/no-non-null-assertion 168 | 169 | @org/another-module:lint: /path/to/work/directory/src/components/DifferentComponent/ComponentTest.spec.tsx 170 | @org/another-module:lint: 2:18 error 'waitFor' is defined but never used @typescript-eslint/no-unused-vars 171 | ``` 172 | 173 | ### Run tests for failing test files 174 | 175 | ```sh 176 | % ght test --files | xargs yarn test 177 | yarn run v1.22.19 178 | $ NODE_ENV=test node ./node_modules/.bin/jest src/moduleA.test.ts src/moduleB.test.ts 179 | ... 180 | ``` 181 | 182 | ## Demo 183 | 184 | https://github.com/raine/ghtool/assets/11027/13a012ac-a854-48a0-b514-9fcbd02c02aa 185 | 186 | ## On required permissions 187 | 188 | The tool currently uses Github's OAuth device flow to authenticate users. To 189 | access workflow job logs through OAuth, which lacks fine-grained permissions, 190 | [the repo scope is required][job-logs-docs], granting scary amount of 191 | permissions. Incidentally, the official GitHub CLI, which I used as reference, 192 | also uses OAuth flow with the `repo` scope and more 193 | ([screenshot][gh-auth-logs]). 194 | 195 | Any ideas on how to improve this are appreciated. 196 | 197 | ## Changelog 198 | 199 | ## 0.10.6 (02.06.2024) 200 | 201 | - jest: Handle more cases with ANSI colors. 202 | 203 | ## 0.10.5 (12.05.2024) 204 | 205 | - jest: Handle colored output. 206 | 207 | ## 0.10.3 (11.05.2024) 208 | 209 | - jest: Allow parsing logs where jest runs using docker-compose. 210 | 211 | ## 0.10.2 (19.09.2023) 212 | 213 | - Fix duplicate test errors in output with `test`. 214 | 215 | ## 0.10.1 (13.09.2023) 216 | 217 | - Print errors as soon as first the pending job fails, i.e. don't wait for all 218 | to complete. 219 | 220 | ## 0.10.0 (04.09.2023) 221 | 222 | - The `test`, `build` and `lint` subcommands now wait for pending jobs the same 223 | way as `all` subcommand. 224 | 225 | ## 0.9.0 (29.08.2023) 226 | 227 | - Added a way to login with a provided access token. 228 | 229 | ## 0.8.0 (27.08.2023) 230 | 231 | - Added `ght all` subcommand. 232 | 233 | ## 0.7.2 (26.08.2023) 234 | 235 | - Allow running commands from subdirectories within a Git repository. 236 | 237 | ## 0.7.0 (26.08.2023) 238 | 239 | - Renamed `typecheck` command to `build`. 240 | - Renamed `tests` command to `test`. 241 | 242 | [crates-badge]: https://img.shields.io/crates/v/ghtool.svg 243 | [crates-url]: https://crates.io/crates/ghtool 244 | [build-badge]: https://github.com/raine/ghtool/actions/workflows/rust.yml/badge.svg 245 | [job-logs-docs]: https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#download-job-logs-for-a-workflow-run 246 | [gh-auth-logs]: https://github.com/raine/ghtool/assets/11027/c5b86639-07d0-4737-a2bc-519ead2f3b9f 247 | [personal-access-token]: https://github.com/settings/tokens 248 | -------------------------------------------------------------------------------- /ghtool/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /ghtool/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "Inflector" 7 | version = "0.11.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" 10 | 11 | [[package]] 12 | name = "addr2line" 13 | version = "0.19.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" 16 | dependencies = [ 17 | "gimli", 18 | ] 19 | 20 | [[package]] 21 | name = "adler" 22 | version = "1.0.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 25 | 26 | [[package]] 27 | name = "aliasable" 28 | version = "0.1.3" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 31 | 32 | [[package]] 33 | name = "android-tzdata" 34 | version = "0.1.1" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 37 | 38 | [[package]] 39 | name = "android_system_properties" 40 | version = "0.1.5" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 43 | dependencies = [ 44 | "libc", 45 | ] 46 | 47 | [[package]] 48 | name = "arc-swap" 49 | version = "1.6.0" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" 52 | 53 | [[package]] 54 | name = "ascii" 55 | version = "0.9.3" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" 58 | 59 | [[package]] 60 | name = "async-trait" 61 | version = "0.1.68" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" 64 | dependencies = [ 65 | "proc-macro2", 66 | "quote", 67 | "syn 2.0.18", 68 | ] 69 | 70 | [[package]] 71 | name = "autocfg" 72 | version = "1.1.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 75 | 76 | [[package]] 77 | name = "backtrace" 78 | version = "0.3.67" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" 81 | dependencies = [ 82 | "addr2line", 83 | "cc", 84 | "cfg-if", 85 | "libc", 86 | "miniz_oxide", 87 | "object", 88 | "rustc-demangle", 89 | ] 90 | 91 | [[package]] 92 | name = "base64" 93 | version = "0.13.1" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 96 | 97 | [[package]] 98 | name = "base64" 99 | version = "0.21.2" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" 102 | 103 | [[package]] 104 | name = "bitflags" 105 | version = "1.3.2" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 108 | 109 | [[package]] 110 | name = "bumpalo" 111 | version = "3.13.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" 114 | 115 | [[package]] 116 | name = "byteorder" 117 | version = "1.4.3" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 120 | 121 | [[package]] 122 | name = "bytes" 123 | version = "1.4.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" 126 | 127 | [[package]] 128 | name = "cc" 129 | version = "1.0.79" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" 132 | 133 | [[package]] 134 | name = "cfg-if" 135 | version = "1.0.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 138 | 139 | [[package]] 140 | name = "chrono" 141 | version = "0.4.26" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" 144 | dependencies = [ 145 | "android-tzdata", 146 | "iana-time-zone", 147 | "num-traits", 148 | "serde", 149 | "winapi", 150 | ] 151 | 152 | [[package]] 153 | name = "color-eyre" 154 | version = "0.6.2" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204" 157 | dependencies = [ 158 | "backtrace", 159 | "color-spantrace", 160 | "eyre", 161 | "indenter", 162 | "once_cell", 163 | "owo-colors", 164 | "tracing-error", 165 | ] 166 | 167 | [[package]] 168 | name = "color-spantrace" 169 | version = "0.2.0" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "1ba75b3d9449ecdccb27ecbc479fdc0b87fa2dd43d2f8298f9bf0e59aacc8dce" 172 | dependencies = [ 173 | "once_cell", 174 | "owo-colors", 175 | "tracing-core", 176 | "tracing-error", 177 | ] 178 | 179 | [[package]] 180 | name = "combine" 181 | version = "3.8.1" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" 184 | dependencies = [ 185 | "ascii", 186 | "byteorder", 187 | "either", 188 | "memchr", 189 | "unreachable", 190 | ] 191 | 192 | [[package]] 193 | name = "core-foundation" 194 | version = "0.9.3" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" 197 | dependencies = [ 198 | "core-foundation-sys", 199 | "libc", 200 | ] 201 | 202 | [[package]] 203 | name = "core-foundation-sys" 204 | version = "0.8.4" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" 207 | 208 | [[package]] 209 | name = "counter" 210 | version = "0.5.7" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "2d458e66999348f56fd3ffcfbb7f7951542075ca8359687c703de6500c1ddccd" 213 | dependencies = [ 214 | "num-traits", 215 | ] 216 | 217 | [[package]] 218 | name = "cynic" 219 | version = "3.0.2" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "93fac5d6a53524745411ddb53bd4801a849566bba4f6898b804158612cc56326" 222 | dependencies = [ 223 | "cynic-proc-macros", 224 | "ref-cast", 225 | "serde", 226 | "serde_json", 227 | "static_assertions", 228 | "thiserror", 229 | ] 230 | 231 | [[package]] 232 | name = "cynic-codegen" 233 | version = "3.0.2" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "722fb3cf6594afc99eba80bc9fa2747a997b6f5a0051b4d8752976b9cc178350" 236 | dependencies = [ 237 | "counter", 238 | "darling", 239 | "graphql-parser", 240 | "once_cell", 241 | "ouroboros", 242 | "proc-macro2", 243 | "quote", 244 | "strsim", 245 | "syn 1.0.109", 246 | "thiserror", 247 | ] 248 | 249 | [[package]] 250 | name = "cynic-proc-macros" 251 | version = "3.0.2" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "c2b0899572fc3bd0e9cbb5520561c8074ac7343490ff3804d007eb0df15534ed" 254 | dependencies = [ 255 | "cynic-codegen", 256 | "quote", 257 | "syn 1.0.109", 258 | ] 259 | 260 | [[package]] 261 | name = "darling" 262 | version = "0.14.4" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" 265 | dependencies = [ 266 | "darling_core", 267 | "darling_macro", 268 | ] 269 | 270 | [[package]] 271 | name = "darling_core" 272 | version = "0.14.4" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" 275 | dependencies = [ 276 | "fnv", 277 | "ident_case", 278 | "proc-macro2", 279 | "quote", 280 | "strsim", 281 | "syn 1.0.109", 282 | ] 283 | 284 | [[package]] 285 | name = "darling_macro" 286 | version = "0.14.4" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" 289 | dependencies = [ 290 | "darling_core", 291 | "quote", 292 | "syn 1.0.109", 293 | ] 294 | 295 | [[package]] 296 | name = "doc-comment" 297 | version = "0.3.3" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" 300 | 301 | [[package]] 302 | name = "either" 303 | version = "1.8.1" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" 306 | 307 | [[package]] 308 | name = "eyre" 309 | version = "0.6.8" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb" 312 | dependencies = [ 313 | "indenter", 314 | "once_cell", 315 | ] 316 | 317 | [[package]] 318 | name = "fnv" 319 | version = "1.0.7" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 322 | 323 | [[package]] 324 | name = "form_urlencoded" 325 | version = "1.2.0" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" 328 | dependencies = [ 329 | "percent-encoding", 330 | ] 331 | 332 | [[package]] 333 | name = "futures" 334 | version = "0.3.28" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" 337 | dependencies = [ 338 | "futures-channel", 339 | "futures-core", 340 | "futures-executor", 341 | "futures-io", 342 | "futures-sink", 343 | "futures-task", 344 | "futures-util", 345 | ] 346 | 347 | [[package]] 348 | name = "futures-channel" 349 | version = "0.3.28" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" 352 | dependencies = [ 353 | "futures-core", 354 | "futures-sink", 355 | ] 356 | 357 | [[package]] 358 | name = "futures-core" 359 | version = "0.3.28" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" 362 | 363 | [[package]] 364 | name = "futures-executor" 365 | version = "0.3.28" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" 368 | dependencies = [ 369 | "futures-core", 370 | "futures-task", 371 | "futures-util", 372 | ] 373 | 374 | [[package]] 375 | name = "futures-io" 376 | version = "0.3.28" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" 379 | 380 | [[package]] 381 | name = "futures-macro" 382 | version = "0.3.28" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" 385 | dependencies = [ 386 | "proc-macro2", 387 | "quote", 388 | "syn 2.0.18", 389 | ] 390 | 391 | [[package]] 392 | name = "futures-sink" 393 | version = "0.3.28" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" 396 | 397 | [[package]] 398 | name = "futures-task" 399 | version = "0.3.28" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" 402 | 403 | [[package]] 404 | name = "futures-util" 405 | version = "0.3.28" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" 408 | dependencies = [ 409 | "futures-channel", 410 | "futures-core", 411 | "futures-io", 412 | "futures-macro", 413 | "futures-sink", 414 | "futures-task", 415 | "memchr", 416 | "pin-project-lite", 417 | "pin-utils", 418 | "slab", 419 | ] 420 | 421 | [[package]] 422 | name = "gimli" 423 | version = "0.27.2" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4" 426 | 427 | [[package]] 428 | name = "gittool" 429 | version = "0.1.0" 430 | dependencies = [ 431 | "color-eyre", 432 | "cynic", 433 | "cynic-codegen", 434 | "eyre", 435 | "octocrab", 436 | "serde_json", 437 | "tokio", 438 | "tracing", 439 | "tracing-subscriber", 440 | ] 441 | 442 | [[package]] 443 | name = "graphql-parser" 444 | version = "0.4.0" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474" 447 | dependencies = [ 448 | "combine", 449 | "thiserror", 450 | ] 451 | 452 | [[package]] 453 | name = "heck" 454 | version = "0.4.1" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 457 | 458 | [[package]] 459 | name = "hermit-abi" 460 | version = "0.2.6" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" 463 | dependencies = [ 464 | "libc", 465 | ] 466 | 467 | [[package]] 468 | name = "http" 469 | version = "0.2.9" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" 472 | dependencies = [ 473 | "bytes", 474 | "fnv", 475 | "itoa", 476 | ] 477 | 478 | [[package]] 479 | name = "http-body" 480 | version = "0.4.5" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" 483 | dependencies = [ 484 | "bytes", 485 | "http", 486 | "pin-project-lite", 487 | ] 488 | 489 | [[package]] 490 | name = "http-range-header" 491 | version = "0.3.0" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" 494 | 495 | [[package]] 496 | name = "httparse" 497 | version = "1.8.0" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" 500 | 501 | [[package]] 502 | name = "httpdate" 503 | version = "1.0.2" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" 506 | 507 | [[package]] 508 | name = "hyper" 509 | version = "0.14.26" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" 512 | dependencies = [ 513 | "bytes", 514 | "futures-channel", 515 | "futures-core", 516 | "futures-util", 517 | "http", 518 | "http-body", 519 | "httparse", 520 | "httpdate", 521 | "itoa", 522 | "pin-project-lite", 523 | "socket2", 524 | "tokio", 525 | "tower-service", 526 | "tracing", 527 | "want", 528 | ] 529 | 530 | [[package]] 531 | name = "hyper-rustls" 532 | version = "0.24.0" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "0646026eb1b3eea4cd9ba47912ea5ce9cc07713d105b1a14698f4e6433d348b7" 535 | dependencies = [ 536 | "http", 537 | "hyper", 538 | "log", 539 | "rustls", 540 | "rustls-native-certs", 541 | "tokio", 542 | "tokio-rustls", 543 | ] 544 | 545 | [[package]] 546 | name = "hyper-timeout" 547 | version = "0.4.1" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" 550 | dependencies = [ 551 | "hyper", 552 | "pin-project-lite", 553 | "tokio", 554 | "tokio-io-timeout", 555 | ] 556 | 557 | [[package]] 558 | name = "iana-time-zone" 559 | version = "0.1.57" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" 562 | dependencies = [ 563 | "android_system_properties", 564 | "core-foundation-sys", 565 | "iana-time-zone-haiku", 566 | "js-sys", 567 | "wasm-bindgen", 568 | "windows", 569 | ] 570 | 571 | [[package]] 572 | name = "iana-time-zone-haiku" 573 | version = "0.1.2" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 576 | dependencies = [ 577 | "cc", 578 | ] 579 | 580 | [[package]] 581 | name = "ident_case" 582 | version = "1.0.1" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 585 | 586 | [[package]] 587 | name = "idna" 588 | version = "0.4.0" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" 591 | dependencies = [ 592 | "unicode-bidi", 593 | "unicode-normalization", 594 | ] 595 | 596 | [[package]] 597 | name = "indenter" 598 | version = "0.3.3" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 601 | 602 | [[package]] 603 | name = "itoa" 604 | version = "1.0.6" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" 607 | 608 | [[package]] 609 | name = "js-sys" 610 | version = "0.3.63" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" 613 | dependencies = [ 614 | "wasm-bindgen", 615 | ] 616 | 617 | [[package]] 618 | name = "jsonwebtoken" 619 | version = "8.3.0" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" 622 | dependencies = [ 623 | "base64 0.21.2", 624 | "pem", 625 | "ring", 626 | "serde", 627 | "serde_json", 628 | "simple_asn1", 629 | ] 630 | 631 | [[package]] 632 | name = "lazy_static" 633 | version = "1.4.0" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 636 | 637 | [[package]] 638 | name = "libc" 639 | version = "0.2.146" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" 642 | 643 | [[package]] 644 | name = "log" 645 | version = "0.4.18" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" 648 | 649 | [[package]] 650 | name = "matchers" 651 | version = "0.1.0" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 654 | dependencies = [ 655 | "regex-automata", 656 | ] 657 | 658 | [[package]] 659 | name = "memchr" 660 | version = "2.5.0" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 663 | 664 | [[package]] 665 | name = "miniz_oxide" 666 | version = "0.6.2" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" 669 | dependencies = [ 670 | "adler", 671 | ] 672 | 673 | [[package]] 674 | name = "mio" 675 | version = "0.8.8" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" 678 | dependencies = [ 679 | "libc", 680 | "wasi", 681 | "windows-sys 0.48.0", 682 | ] 683 | 684 | [[package]] 685 | name = "nu-ansi-term" 686 | version = "0.46.0" 687 | source = "registry+https://github.com/rust-lang/crates.io-index" 688 | checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 689 | dependencies = [ 690 | "overload", 691 | "winapi", 692 | ] 693 | 694 | [[package]] 695 | name = "num-bigint" 696 | version = "0.4.3" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" 699 | dependencies = [ 700 | "autocfg", 701 | "num-integer", 702 | "num-traits", 703 | ] 704 | 705 | [[package]] 706 | name = "num-integer" 707 | version = "0.1.45" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" 710 | dependencies = [ 711 | "autocfg", 712 | "num-traits", 713 | ] 714 | 715 | [[package]] 716 | name = "num-traits" 717 | version = "0.2.15" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" 720 | dependencies = [ 721 | "autocfg", 722 | ] 723 | 724 | [[package]] 725 | name = "num_cpus" 726 | version = "1.15.0" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" 729 | dependencies = [ 730 | "hermit-abi", 731 | "libc", 732 | ] 733 | 734 | [[package]] 735 | name = "object" 736 | version = "0.30.4" 737 | source = "registry+https://github.com/rust-lang/crates.io-index" 738 | checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" 739 | dependencies = [ 740 | "memchr", 741 | ] 742 | 743 | [[package]] 744 | name = "octocrab" 745 | version = "0.25.1" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "a0bc095e456c43e3afe5a53cdcf11aae1965663b941f7a5efb49b6ef53ce8529" 748 | dependencies = [ 749 | "arc-swap", 750 | "async-trait", 751 | "base64 0.21.2", 752 | "bytes", 753 | "cfg-if", 754 | "chrono", 755 | "either", 756 | "futures", 757 | "futures-util", 758 | "http", 759 | "http-body", 760 | "hyper", 761 | "hyper-rustls", 762 | "hyper-timeout", 763 | "jsonwebtoken", 764 | "once_cell", 765 | "percent-encoding", 766 | "pin-project", 767 | "secrecy", 768 | "serde", 769 | "serde_json", 770 | "serde_path_to_error", 771 | "serde_urlencoded", 772 | "snafu", 773 | "tokio", 774 | "tower", 775 | "tower-http", 776 | "tracing", 777 | "url", 778 | ] 779 | 780 | [[package]] 781 | name = "once_cell" 782 | version = "1.18.0" 783 | source = "registry+https://github.com/rust-lang/crates.io-index" 784 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 785 | 786 | [[package]] 787 | name = "openssl-probe" 788 | version = "0.1.5" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" 791 | 792 | [[package]] 793 | name = "ouroboros" 794 | version = "0.15.6" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "e1358bd1558bd2a083fed428ffeda486fbfb323e698cdda7794259d592ca72db" 797 | dependencies = [ 798 | "aliasable", 799 | "ouroboros_macro", 800 | ] 801 | 802 | [[package]] 803 | name = "ouroboros_macro" 804 | version = "0.15.6" 805 | source = "registry+https://github.com/rust-lang/crates.io-index" 806 | checksum = "5f7d21ccd03305a674437ee1248f3ab5d4b1db095cf1caf49f1713ddf61956b7" 807 | dependencies = [ 808 | "Inflector", 809 | "proc-macro-error", 810 | "proc-macro2", 811 | "quote", 812 | "syn 1.0.109", 813 | ] 814 | 815 | [[package]] 816 | name = "overload" 817 | version = "0.1.1" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 820 | 821 | [[package]] 822 | name = "owo-colors" 823 | version = "3.5.0" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" 826 | 827 | [[package]] 828 | name = "pem" 829 | version = "1.1.1" 830 | source = "registry+https://github.com/rust-lang/crates.io-index" 831 | checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" 832 | dependencies = [ 833 | "base64 0.13.1", 834 | ] 835 | 836 | [[package]] 837 | name = "percent-encoding" 838 | version = "2.3.0" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" 841 | 842 | [[package]] 843 | name = "pin-project" 844 | version = "1.1.0" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "c95a7476719eab1e366eaf73d0260af3021184f18177925b07f54b30089ceead" 847 | dependencies = [ 848 | "pin-project-internal", 849 | ] 850 | 851 | [[package]] 852 | name = "pin-project-internal" 853 | version = "1.1.0" 854 | source = "registry+https://github.com/rust-lang/crates.io-index" 855 | checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" 856 | dependencies = [ 857 | "proc-macro2", 858 | "quote", 859 | "syn 2.0.18", 860 | ] 861 | 862 | [[package]] 863 | name = "pin-project-lite" 864 | version = "0.2.9" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 867 | 868 | [[package]] 869 | name = "pin-utils" 870 | version = "0.1.0" 871 | source = "registry+https://github.com/rust-lang/crates.io-index" 872 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 873 | 874 | [[package]] 875 | name = "proc-macro-error" 876 | version = "1.0.4" 877 | source = "registry+https://github.com/rust-lang/crates.io-index" 878 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 879 | dependencies = [ 880 | "proc-macro-error-attr", 881 | "proc-macro2", 882 | "quote", 883 | "syn 1.0.109", 884 | "version_check", 885 | ] 886 | 887 | [[package]] 888 | name = "proc-macro-error-attr" 889 | version = "1.0.4" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 892 | dependencies = [ 893 | "proc-macro2", 894 | "quote", 895 | "version_check", 896 | ] 897 | 898 | [[package]] 899 | name = "proc-macro2" 900 | version = "1.0.60" 901 | source = "registry+https://github.com/rust-lang/crates.io-index" 902 | checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" 903 | dependencies = [ 904 | "unicode-ident", 905 | ] 906 | 907 | [[package]] 908 | name = "quote" 909 | version = "1.0.28" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" 912 | dependencies = [ 913 | "proc-macro2", 914 | ] 915 | 916 | [[package]] 917 | name = "ref-cast" 918 | version = "1.0.16" 919 | source = "registry+https://github.com/rust-lang/crates.io-index" 920 | checksum = "f43faa91b1c8b36841ee70e97188a869d37ae21759da6846d4be66de5bf7b12c" 921 | dependencies = [ 922 | "ref-cast-impl", 923 | ] 924 | 925 | [[package]] 926 | name = "ref-cast-impl" 927 | version = "1.0.16" 928 | source = "registry+https://github.com/rust-lang/crates.io-index" 929 | checksum = "8d2275aab483050ab2a7364c1a46604865ee7d6906684e08db0f090acf74f9e7" 930 | dependencies = [ 931 | "proc-macro2", 932 | "quote", 933 | "syn 2.0.18", 934 | ] 935 | 936 | [[package]] 937 | name = "regex" 938 | version = "1.8.4" 939 | source = "registry+https://github.com/rust-lang/crates.io-index" 940 | checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" 941 | dependencies = [ 942 | "regex-syntax 0.7.2", 943 | ] 944 | 945 | [[package]] 946 | name = "regex-automata" 947 | version = "0.1.10" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 950 | dependencies = [ 951 | "regex-syntax 0.6.29", 952 | ] 953 | 954 | [[package]] 955 | name = "regex-syntax" 956 | version = "0.6.29" 957 | source = "registry+https://github.com/rust-lang/crates.io-index" 958 | checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 959 | 960 | [[package]] 961 | name = "regex-syntax" 962 | version = "0.7.2" 963 | source = "registry+https://github.com/rust-lang/crates.io-index" 964 | checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" 965 | 966 | [[package]] 967 | name = "ring" 968 | version = "0.16.20" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" 971 | dependencies = [ 972 | "cc", 973 | "libc", 974 | "once_cell", 975 | "spin", 976 | "untrusted", 977 | "web-sys", 978 | "winapi", 979 | ] 980 | 981 | [[package]] 982 | name = "rustc-demangle" 983 | version = "0.1.23" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 986 | 987 | [[package]] 988 | name = "rustls" 989 | version = "0.21.1" 990 | source = "registry+https://github.com/rust-lang/crates.io-index" 991 | checksum = "c911ba11bc8433e811ce56fde130ccf32f5127cab0e0194e9c68c5a5b671791e" 992 | dependencies = [ 993 | "log", 994 | "ring", 995 | "rustls-webpki", 996 | "sct", 997 | ] 998 | 999 | [[package]] 1000 | name = "rustls-native-certs" 1001 | version = "0.6.2" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" 1004 | dependencies = [ 1005 | "openssl-probe", 1006 | "rustls-pemfile", 1007 | "schannel", 1008 | "security-framework", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "rustls-pemfile" 1013 | version = "1.0.2" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" 1016 | dependencies = [ 1017 | "base64 0.21.2", 1018 | ] 1019 | 1020 | [[package]] 1021 | name = "rustls-webpki" 1022 | version = "0.100.1" 1023 | source = "registry+https://github.com/rust-lang/crates.io-index" 1024 | checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" 1025 | dependencies = [ 1026 | "ring", 1027 | "untrusted", 1028 | ] 1029 | 1030 | [[package]] 1031 | name = "ryu" 1032 | version = "1.0.13" 1033 | source = "registry+https://github.com/rust-lang/crates.io-index" 1034 | checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" 1035 | 1036 | [[package]] 1037 | name = "schannel" 1038 | version = "0.1.21" 1039 | source = "registry+https://github.com/rust-lang/crates.io-index" 1040 | checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" 1041 | dependencies = [ 1042 | "windows-sys 0.42.0", 1043 | ] 1044 | 1045 | [[package]] 1046 | name = "sct" 1047 | version = "0.7.0" 1048 | source = "registry+https://github.com/rust-lang/crates.io-index" 1049 | checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" 1050 | dependencies = [ 1051 | "ring", 1052 | "untrusted", 1053 | ] 1054 | 1055 | [[package]] 1056 | name = "secrecy" 1057 | version = "0.8.0" 1058 | source = "registry+https://github.com/rust-lang/crates.io-index" 1059 | checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" 1060 | dependencies = [ 1061 | "zeroize", 1062 | ] 1063 | 1064 | [[package]] 1065 | name = "security-framework" 1066 | version = "2.9.1" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" 1069 | dependencies = [ 1070 | "bitflags", 1071 | "core-foundation", 1072 | "core-foundation-sys", 1073 | "libc", 1074 | "security-framework-sys", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "security-framework-sys" 1079 | version = "2.9.0" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" 1082 | dependencies = [ 1083 | "core-foundation-sys", 1084 | "libc", 1085 | ] 1086 | 1087 | [[package]] 1088 | name = "serde" 1089 | version = "1.0.164" 1090 | source = "registry+https://github.com/rust-lang/crates.io-index" 1091 | checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" 1092 | dependencies = [ 1093 | "serde_derive", 1094 | ] 1095 | 1096 | [[package]] 1097 | name = "serde_derive" 1098 | version = "1.0.164" 1099 | source = "registry+https://github.com/rust-lang/crates.io-index" 1100 | checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" 1101 | dependencies = [ 1102 | "proc-macro2", 1103 | "quote", 1104 | "syn 2.0.18", 1105 | ] 1106 | 1107 | [[package]] 1108 | name = "serde_json" 1109 | version = "1.0.96" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" 1112 | dependencies = [ 1113 | "itoa", 1114 | "ryu", 1115 | "serde", 1116 | ] 1117 | 1118 | [[package]] 1119 | name = "serde_path_to_error" 1120 | version = "0.1.11" 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" 1122 | checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" 1123 | dependencies = [ 1124 | "serde", 1125 | ] 1126 | 1127 | [[package]] 1128 | name = "serde_urlencoded" 1129 | version = "0.7.1" 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" 1131 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1132 | dependencies = [ 1133 | "form_urlencoded", 1134 | "itoa", 1135 | "ryu", 1136 | "serde", 1137 | ] 1138 | 1139 | [[package]] 1140 | name = "sharded-slab" 1141 | version = "0.1.4" 1142 | source = "registry+https://github.com/rust-lang/crates.io-index" 1143 | checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" 1144 | dependencies = [ 1145 | "lazy_static", 1146 | ] 1147 | 1148 | [[package]] 1149 | name = "signal-hook-registry" 1150 | version = "1.4.1" 1151 | source = "registry+https://github.com/rust-lang/crates.io-index" 1152 | checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" 1153 | dependencies = [ 1154 | "libc", 1155 | ] 1156 | 1157 | [[package]] 1158 | name = "simple_asn1" 1159 | version = "0.6.2" 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" 1161 | checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" 1162 | dependencies = [ 1163 | "num-bigint", 1164 | "num-traits", 1165 | "thiserror", 1166 | "time", 1167 | ] 1168 | 1169 | [[package]] 1170 | name = "slab" 1171 | version = "0.4.8" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" 1174 | dependencies = [ 1175 | "autocfg", 1176 | ] 1177 | 1178 | [[package]] 1179 | name = "snafu" 1180 | version = "0.7.4" 1181 | source = "registry+https://github.com/rust-lang/crates.io-index" 1182 | checksum = "cb0656e7e3ffb70f6c39b3c2a86332bb74aa3c679da781642590f3c1118c5045" 1183 | dependencies = [ 1184 | "backtrace", 1185 | "doc-comment", 1186 | "snafu-derive", 1187 | ] 1188 | 1189 | [[package]] 1190 | name = "snafu-derive" 1191 | version = "0.7.4" 1192 | source = "registry+https://github.com/rust-lang/crates.io-index" 1193 | checksum = "475b3bbe5245c26f2d8a6f62d67c1f30eb9fffeccee721c45d162c3ebbdf81b2" 1194 | dependencies = [ 1195 | "heck", 1196 | "proc-macro2", 1197 | "quote", 1198 | "syn 1.0.109", 1199 | ] 1200 | 1201 | [[package]] 1202 | name = "socket2" 1203 | version = "0.4.9" 1204 | source = "registry+https://github.com/rust-lang/crates.io-index" 1205 | checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" 1206 | dependencies = [ 1207 | "libc", 1208 | "winapi", 1209 | ] 1210 | 1211 | [[package]] 1212 | name = "spin" 1213 | version = "0.5.2" 1214 | source = "registry+https://github.com/rust-lang/crates.io-index" 1215 | checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" 1216 | 1217 | [[package]] 1218 | name = "static_assertions" 1219 | version = "1.1.0" 1220 | source = "registry+https://github.com/rust-lang/crates.io-index" 1221 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1222 | 1223 | [[package]] 1224 | name = "strsim" 1225 | version = "0.10.0" 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" 1227 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 1228 | 1229 | [[package]] 1230 | name = "syn" 1231 | version = "1.0.109" 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" 1233 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 1234 | dependencies = [ 1235 | "proc-macro2", 1236 | "quote", 1237 | "unicode-ident", 1238 | ] 1239 | 1240 | [[package]] 1241 | name = "syn" 1242 | version = "2.0.18" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" 1245 | dependencies = [ 1246 | "proc-macro2", 1247 | "quote", 1248 | "unicode-ident", 1249 | ] 1250 | 1251 | [[package]] 1252 | name = "thiserror" 1253 | version = "1.0.40" 1254 | source = "registry+https://github.com/rust-lang/crates.io-index" 1255 | checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" 1256 | dependencies = [ 1257 | "thiserror-impl", 1258 | ] 1259 | 1260 | [[package]] 1261 | name = "thiserror-impl" 1262 | version = "1.0.40" 1263 | source = "registry+https://github.com/rust-lang/crates.io-index" 1264 | checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" 1265 | dependencies = [ 1266 | "proc-macro2", 1267 | "quote", 1268 | "syn 2.0.18", 1269 | ] 1270 | 1271 | [[package]] 1272 | name = "thread_local" 1273 | version = "1.1.7" 1274 | source = "registry+https://github.com/rust-lang/crates.io-index" 1275 | checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" 1276 | dependencies = [ 1277 | "cfg-if", 1278 | "once_cell", 1279 | ] 1280 | 1281 | [[package]] 1282 | name = "time" 1283 | version = "0.3.22" 1284 | source = "registry+https://github.com/rust-lang/crates.io-index" 1285 | checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" 1286 | dependencies = [ 1287 | "itoa", 1288 | "serde", 1289 | "time-core", 1290 | "time-macros", 1291 | ] 1292 | 1293 | [[package]] 1294 | name = "time-core" 1295 | version = "0.1.1" 1296 | source = "registry+https://github.com/rust-lang/crates.io-index" 1297 | checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" 1298 | 1299 | [[package]] 1300 | name = "time-macros" 1301 | version = "0.2.9" 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" 1303 | checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" 1304 | dependencies = [ 1305 | "time-core", 1306 | ] 1307 | 1308 | [[package]] 1309 | name = "tinyvec" 1310 | version = "1.6.0" 1311 | source = "registry+https://github.com/rust-lang/crates.io-index" 1312 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 1313 | dependencies = [ 1314 | "tinyvec_macros", 1315 | ] 1316 | 1317 | [[package]] 1318 | name = "tinyvec_macros" 1319 | version = "0.1.1" 1320 | source = "registry+https://github.com/rust-lang/crates.io-index" 1321 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1322 | 1323 | [[package]] 1324 | name = "tokio" 1325 | version = "1.28.2" 1326 | source = "registry+https://github.com/rust-lang/crates.io-index" 1327 | checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" 1328 | dependencies = [ 1329 | "autocfg", 1330 | "bytes", 1331 | "libc", 1332 | "mio", 1333 | "num_cpus", 1334 | "pin-project-lite", 1335 | "signal-hook-registry", 1336 | "socket2", 1337 | "tokio-macros", 1338 | "windows-sys 0.48.0", 1339 | ] 1340 | 1341 | [[package]] 1342 | name = "tokio-io-timeout" 1343 | version = "1.2.0" 1344 | source = "registry+https://github.com/rust-lang/crates.io-index" 1345 | checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" 1346 | dependencies = [ 1347 | "pin-project-lite", 1348 | "tokio", 1349 | ] 1350 | 1351 | [[package]] 1352 | name = "tokio-macros" 1353 | version = "2.1.0" 1354 | source = "registry+https://github.com/rust-lang/crates.io-index" 1355 | checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" 1356 | dependencies = [ 1357 | "proc-macro2", 1358 | "quote", 1359 | "syn 2.0.18", 1360 | ] 1361 | 1362 | [[package]] 1363 | name = "tokio-rustls" 1364 | version = "0.24.1" 1365 | source = "registry+https://github.com/rust-lang/crates.io-index" 1366 | checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 1367 | dependencies = [ 1368 | "rustls", 1369 | "tokio", 1370 | ] 1371 | 1372 | [[package]] 1373 | name = "tokio-util" 1374 | version = "0.7.8" 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" 1376 | checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" 1377 | dependencies = [ 1378 | "bytes", 1379 | "futures-core", 1380 | "futures-sink", 1381 | "pin-project-lite", 1382 | "tokio", 1383 | ] 1384 | 1385 | [[package]] 1386 | name = "tower" 1387 | version = "0.4.13" 1388 | source = "registry+https://github.com/rust-lang/crates.io-index" 1389 | checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 1390 | dependencies = [ 1391 | "futures-core", 1392 | "futures-util", 1393 | "pin-project", 1394 | "pin-project-lite", 1395 | "tokio", 1396 | "tokio-util", 1397 | "tower-layer", 1398 | "tower-service", 1399 | "tracing", 1400 | ] 1401 | 1402 | [[package]] 1403 | name = "tower-http" 1404 | version = "0.4.0" 1405 | source = "registry+https://github.com/rust-lang/crates.io-index" 1406 | checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" 1407 | dependencies = [ 1408 | "bitflags", 1409 | "bytes", 1410 | "futures-core", 1411 | "futures-util", 1412 | "http", 1413 | "http-body", 1414 | "http-range-header", 1415 | "pin-project-lite", 1416 | "tower-layer", 1417 | "tower-service", 1418 | "tracing", 1419 | ] 1420 | 1421 | [[package]] 1422 | name = "tower-layer" 1423 | version = "0.3.2" 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" 1425 | checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" 1426 | 1427 | [[package]] 1428 | name = "tower-service" 1429 | version = "0.3.2" 1430 | source = "registry+https://github.com/rust-lang/crates.io-index" 1431 | checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" 1432 | 1433 | [[package]] 1434 | name = "tracing" 1435 | version = "0.1.37" 1436 | source = "registry+https://github.com/rust-lang/crates.io-index" 1437 | checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" 1438 | dependencies = [ 1439 | "cfg-if", 1440 | "log", 1441 | "pin-project-lite", 1442 | "tracing-attributes", 1443 | "tracing-core", 1444 | ] 1445 | 1446 | [[package]] 1447 | name = "tracing-attributes" 1448 | version = "0.1.24" 1449 | source = "registry+https://github.com/rust-lang/crates.io-index" 1450 | checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" 1451 | dependencies = [ 1452 | "proc-macro2", 1453 | "quote", 1454 | "syn 2.0.18", 1455 | ] 1456 | 1457 | [[package]] 1458 | name = "tracing-core" 1459 | version = "0.1.31" 1460 | source = "registry+https://github.com/rust-lang/crates.io-index" 1461 | checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" 1462 | dependencies = [ 1463 | "once_cell", 1464 | "valuable", 1465 | ] 1466 | 1467 | [[package]] 1468 | name = "tracing-error" 1469 | version = "0.2.0" 1470 | source = "registry+https://github.com/rust-lang/crates.io-index" 1471 | checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" 1472 | dependencies = [ 1473 | "tracing", 1474 | "tracing-subscriber", 1475 | ] 1476 | 1477 | [[package]] 1478 | name = "tracing-subscriber" 1479 | version = "0.3.17" 1480 | source = "registry+https://github.com/rust-lang/crates.io-index" 1481 | checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" 1482 | dependencies = [ 1483 | "matchers", 1484 | "nu-ansi-term", 1485 | "once_cell", 1486 | "regex", 1487 | "sharded-slab", 1488 | "thread_local", 1489 | "tracing", 1490 | "tracing-core", 1491 | ] 1492 | 1493 | [[package]] 1494 | name = "try-lock" 1495 | version = "0.2.4" 1496 | source = "registry+https://github.com/rust-lang/crates.io-index" 1497 | checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" 1498 | 1499 | [[package]] 1500 | name = "unicode-bidi" 1501 | version = "0.3.13" 1502 | source = "registry+https://github.com/rust-lang/crates.io-index" 1503 | checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" 1504 | 1505 | [[package]] 1506 | name = "unicode-ident" 1507 | version = "1.0.9" 1508 | source = "registry+https://github.com/rust-lang/crates.io-index" 1509 | checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" 1510 | 1511 | [[package]] 1512 | name = "unicode-normalization" 1513 | version = "0.1.22" 1514 | source = "registry+https://github.com/rust-lang/crates.io-index" 1515 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" 1516 | dependencies = [ 1517 | "tinyvec", 1518 | ] 1519 | 1520 | [[package]] 1521 | name = "unreachable" 1522 | version = "1.0.0" 1523 | source = "registry+https://github.com/rust-lang/crates.io-index" 1524 | checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" 1525 | dependencies = [ 1526 | "void", 1527 | ] 1528 | 1529 | [[package]] 1530 | name = "untrusted" 1531 | version = "0.7.1" 1532 | source = "registry+https://github.com/rust-lang/crates.io-index" 1533 | checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" 1534 | 1535 | [[package]] 1536 | name = "url" 1537 | version = "2.4.0" 1538 | source = "registry+https://github.com/rust-lang/crates.io-index" 1539 | checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" 1540 | dependencies = [ 1541 | "form_urlencoded", 1542 | "idna", 1543 | "percent-encoding", 1544 | "serde", 1545 | ] 1546 | 1547 | [[package]] 1548 | name = "valuable" 1549 | version = "0.1.0" 1550 | source = "registry+https://github.com/rust-lang/crates.io-index" 1551 | checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" 1552 | 1553 | [[package]] 1554 | name = "version_check" 1555 | version = "0.9.4" 1556 | source = "registry+https://github.com/rust-lang/crates.io-index" 1557 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1558 | 1559 | [[package]] 1560 | name = "void" 1561 | version = "1.0.2" 1562 | source = "registry+https://github.com/rust-lang/crates.io-index" 1563 | checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" 1564 | 1565 | [[package]] 1566 | name = "want" 1567 | version = "0.3.0" 1568 | source = "registry+https://github.com/rust-lang/crates.io-index" 1569 | checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" 1570 | dependencies = [ 1571 | "log", 1572 | "try-lock", 1573 | ] 1574 | 1575 | [[package]] 1576 | name = "wasi" 1577 | version = "0.11.0+wasi-snapshot-preview1" 1578 | source = "registry+https://github.com/rust-lang/crates.io-index" 1579 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1580 | 1581 | [[package]] 1582 | name = "wasm-bindgen" 1583 | version = "0.2.86" 1584 | source = "registry+https://github.com/rust-lang/crates.io-index" 1585 | checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" 1586 | dependencies = [ 1587 | "cfg-if", 1588 | "wasm-bindgen-macro", 1589 | ] 1590 | 1591 | [[package]] 1592 | name = "wasm-bindgen-backend" 1593 | version = "0.2.86" 1594 | source = "registry+https://github.com/rust-lang/crates.io-index" 1595 | checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" 1596 | dependencies = [ 1597 | "bumpalo", 1598 | "log", 1599 | "once_cell", 1600 | "proc-macro2", 1601 | "quote", 1602 | "syn 2.0.18", 1603 | "wasm-bindgen-shared", 1604 | ] 1605 | 1606 | [[package]] 1607 | name = "wasm-bindgen-macro" 1608 | version = "0.2.86" 1609 | source = "registry+https://github.com/rust-lang/crates.io-index" 1610 | checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" 1611 | dependencies = [ 1612 | "quote", 1613 | "wasm-bindgen-macro-support", 1614 | ] 1615 | 1616 | [[package]] 1617 | name = "wasm-bindgen-macro-support" 1618 | version = "0.2.86" 1619 | source = "registry+https://github.com/rust-lang/crates.io-index" 1620 | checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" 1621 | dependencies = [ 1622 | "proc-macro2", 1623 | "quote", 1624 | "syn 2.0.18", 1625 | "wasm-bindgen-backend", 1626 | "wasm-bindgen-shared", 1627 | ] 1628 | 1629 | [[package]] 1630 | name = "wasm-bindgen-shared" 1631 | version = "0.2.86" 1632 | source = "registry+https://github.com/rust-lang/crates.io-index" 1633 | checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" 1634 | 1635 | [[package]] 1636 | name = "web-sys" 1637 | version = "0.3.63" 1638 | source = "registry+https://github.com/rust-lang/crates.io-index" 1639 | checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2" 1640 | dependencies = [ 1641 | "js-sys", 1642 | "wasm-bindgen", 1643 | ] 1644 | 1645 | [[package]] 1646 | name = "winapi" 1647 | version = "0.3.9" 1648 | source = "registry+https://github.com/rust-lang/crates.io-index" 1649 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1650 | dependencies = [ 1651 | "winapi-i686-pc-windows-gnu", 1652 | "winapi-x86_64-pc-windows-gnu", 1653 | ] 1654 | 1655 | [[package]] 1656 | name = "winapi-i686-pc-windows-gnu" 1657 | version = "0.4.0" 1658 | source = "registry+https://github.com/rust-lang/crates.io-index" 1659 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1660 | 1661 | [[package]] 1662 | name = "winapi-x86_64-pc-windows-gnu" 1663 | version = "0.4.0" 1664 | source = "registry+https://github.com/rust-lang/crates.io-index" 1665 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1666 | 1667 | [[package]] 1668 | name = "windows" 1669 | version = "0.48.0" 1670 | source = "registry+https://github.com/rust-lang/crates.io-index" 1671 | checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" 1672 | dependencies = [ 1673 | "windows-targets", 1674 | ] 1675 | 1676 | [[package]] 1677 | name = "windows-sys" 1678 | version = "0.42.0" 1679 | source = "registry+https://github.com/rust-lang/crates.io-index" 1680 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 1681 | dependencies = [ 1682 | "windows_aarch64_gnullvm 0.42.2", 1683 | "windows_aarch64_msvc 0.42.2", 1684 | "windows_i686_gnu 0.42.2", 1685 | "windows_i686_msvc 0.42.2", 1686 | "windows_x86_64_gnu 0.42.2", 1687 | "windows_x86_64_gnullvm 0.42.2", 1688 | "windows_x86_64_msvc 0.42.2", 1689 | ] 1690 | 1691 | [[package]] 1692 | name = "windows-sys" 1693 | version = "0.48.0" 1694 | source = "registry+https://github.com/rust-lang/crates.io-index" 1695 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 1696 | dependencies = [ 1697 | "windows-targets", 1698 | ] 1699 | 1700 | [[package]] 1701 | name = "windows-targets" 1702 | version = "0.48.0" 1703 | source = "registry+https://github.com/rust-lang/crates.io-index" 1704 | checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" 1705 | dependencies = [ 1706 | "windows_aarch64_gnullvm 0.48.0", 1707 | "windows_aarch64_msvc 0.48.0", 1708 | "windows_i686_gnu 0.48.0", 1709 | "windows_i686_msvc 0.48.0", 1710 | "windows_x86_64_gnu 0.48.0", 1711 | "windows_x86_64_gnullvm 0.48.0", 1712 | "windows_x86_64_msvc 0.48.0", 1713 | ] 1714 | 1715 | [[package]] 1716 | name = "windows_aarch64_gnullvm" 1717 | version = "0.42.2" 1718 | source = "registry+https://github.com/rust-lang/crates.io-index" 1719 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 1720 | 1721 | [[package]] 1722 | name = "windows_aarch64_gnullvm" 1723 | version = "0.48.0" 1724 | source = "registry+https://github.com/rust-lang/crates.io-index" 1725 | checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" 1726 | 1727 | [[package]] 1728 | name = "windows_aarch64_msvc" 1729 | version = "0.42.2" 1730 | source = "registry+https://github.com/rust-lang/crates.io-index" 1731 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 1732 | 1733 | [[package]] 1734 | name = "windows_aarch64_msvc" 1735 | version = "0.48.0" 1736 | source = "registry+https://github.com/rust-lang/crates.io-index" 1737 | checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" 1738 | 1739 | [[package]] 1740 | name = "windows_i686_gnu" 1741 | version = "0.42.2" 1742 | source = "registry+https://github.com/rust-lang/crates.io-index" 1743 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 1744 | 1745 | [[package]] 1746 | name = "windows_i686_gnu" 1747 | version = "0.48.0" 1748 | source = "registry+https://github.com/rust-lang/crates.io-index" 1749 | checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" 1750 | 1751 | [[package]] 1752 | name = "windows_i686_msvc" 1753 | version = "0.42.2" 1754 | source = "registry+https://github.com/rust-lang/crates.io-index" 1755 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 1756 | 1757 | [[package]] 1758 | name = "windows_i686_msvc" 1759 | version = "0.48.0" 1760 | source = "registry+https://github.com/rust-lang/crates.io-index" 1761 | checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" 1762 | 1763 | [[package]] 1764 | name = "windows_x86_64_gnu" 1765 | version = "0.42.2" 1766 | source = "registry+https://github.com/rust-lang/crates.io-index" 1767 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 1768 | 1769 | [[package]] 1770 | name = "windows_x86_64_gnu" 1771 | version = "0.48.0" 1772 | source = "registry+https://github.com/rust-lang/crates.io-index" 1773 | checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" 1774 | 1775 | [[package]] 1776 | name = "windows_x86_64_gnullvm" 1777 | version = "0.42.2" 1778 | source = "registry+https://github.com/rust-lang/crates.io-index" 1779 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 1780 | 1781 | [[package]] 1782 | name = "windows_x86_64_gnullvm" 1783 | version = "0.48.0" 1784 | source = "registry+https://github.com/rust-lang/crates.io-index" 1785 | checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" 1786 | 1787 | [[package]] 1788 | name = "windows_x86_64_msvc" 1789 | version = "0.42.2" 1790 | source = "registry+https://github.com/rust-lang/crates.io-index" 1791 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 1792 | 1793 | [[package]] 1794 | name = "windows_x86_64_msvc" 1795 | version = "0.48.0" 1796 | source = "registry+https://github.com/rust-lang/crates.io-index" 1797 | checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" 1798 | 1799 | [[package]] 1800 | name = "zeroize" 1801 | version = "1.6.0" 1802 | source = "registry+https://github.com/rust-lang/crates.io-index" 1803 | checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" 1804 | -------------------------------------------------------------------------------- /ghtool/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ghtool" 3 | version = "0.10.6" 4 | edition = "2021" 5 | description = "A command-line tool for interacting with Github API with some specialized features oriented around Checks" 6 | license = "MIT" 7 | repository = "https://github.com/raine/ghtool" 8 | readme = "../README.md" 9 | 10 | [[bin]] 11 | name = "ght" 12 | path = "src/bin/main.rs" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | color-eyre = "0.6.2" 18 | cynic = { version = "3.7.0", features = ["serde_json", "http-reqwest"] } 19 | eyre = "0.6.8" 20 | serde_json = "1.0.105" 21 | tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } 22 | tracing = "0.1.37" 23 | tracing-subscriber = {version = "0.3.17", default-features = false, features = ["env-filter", "fmt", "ansi"]} 24 | cynic-github-schema = { path = "../github_schema", version = "0.1.0" } 25 | serde = "1.0.188" 26 | http = "1.1.0" 27 | toml = "0.8.0" 28 | regex = "1.9.4" 29 | dirs = "5.0.1" 30 | futures = "0.3.28" 31 | strip-ansi-escapes = "0.2.0" 32 | lazy_static = "1.4.0" 33 | clap = { version = "4.4.1", features = ["derive"] } 34 | term_size = "0.3.2" 35 | sled = { version = "0.34.7", features = ["compression"] } 36 | reqwest = { version = "0.12.4", features = ["stream"] } 37 | bytes = "1.4.0" 38 | indicatif = "0.17.6" 39 | open = "5.0.0" 40 | keyring = "2.0.5" 41 | chrono = "0.4.28" 42 | thiserror = "1.0.47" 43 | 44 | [dev-dependencies] 45 | pretty_assertions = "1.4.0" 46 | 47 | [build-dependencies] 48 | cynic-codegen = { version = "3.7.0", features = ["rkyv"] } 49 | -------------------------------------------------------------------------------- /ghtool/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | cynic_codegen::register_schema("github") 3 | .from_sdl_file("./github.graphql") 4 | .unwrap() 5 | .as_default() 6 | .unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /ghtool/github.graphql: -------------------------------------------------------------------------------- 1 | ../github_schema/github.graphql -------------------------------------------------------------------------------- /ghtool/hurl/github-device-flow: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow 2 | POST https://github.com/login/device/code 3 | Content-Type: application/x-www-form-urlencoded 4 | `client_id=32a2525cc736ee9b63ae&scope=repo+read%3Aorg` 5 | 6 | HTTP 200 7 | [Captures] 8 | device_code: regex "device_code=(.*?)&" 9 | 10 | POST https://github.com/login/oauth/access_token 11 | Accept: application/json 12 | Content-Type: application/x-www-form-urlencoded 13 | `client_id=Iv1.1bbd5e03617adebb&device_code={{device_code}}&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code` 14 | -------------------------------------------------------------------------------- /ghtool/justfile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo build 3 | 4 | build-release: 5 | cargo build --release 6 | 7 | publish: 8 | cargo publish 9 | 10 | run *FLAGS: 11 | cargo run {{FLAGS}} 12 | 13 | test *FLAGS: 14 | cargo test {{FLAGS}} 15 | 16 | testw *FLAGS: 17 | fd .rs | entr -r cargo test {{FLAGS}} 18 | 19 | install: 20 | cargo install --path . 21 | -------------------------------------------------------------------------------- /ghtool/src/bin/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use commands::{auth, handle_all_command, handle_command, CommandType}; 3 | use eyre::Result; 4 | use ghtool::{ 5 | cli::{self, Commands}, 6 | commands, setup, term, 7 | }; 8 | use setup::setup; 9 | use term::exit_with_error; 10 | 11 | async fn run() -> Result<()> { 12 | let cli = setup()?; 13 | 14 | match &cli.command { 15 | Some(Commands::Test { files }) => handle_command(CommandType::Test, &cli, *files).await, 16 | Some(Commands::Lint { files }) => handle_command(CommandType::Lint, &cli, *files).await, 17 | Some(Commands::Build { files }) => handle_command(CommandType::Build, &cli, *files).await, 18 | Some(Commands::All {}) => handle_all_command(&cli).await, 19 | Some(Commands::Login { stdin }) => { 20 | auth::login(*stdin).await?; 21 | Ok(()) 22 | } 23 | Some(Commands::Logout {}) => { 24 | auth::logout()?; 25 | Ok(()) 26 | } 27 | None => { 28 | // Show help if no command is given. arg_required_else_help clap thing is supposed to 29 | // do this but that doesn't work if some arguments, but no command, are given 30 | cli::Cli::parse_from(["--help"]); 31 | Ok(()) 32 | } 33 | } 34 | } 35 | 36 | #[tokio::main] 37 | async fn main() { 38 | if let Err(e) = run().await { 39 | let _ = exit_with_error::(e); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ghtool/src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use eyre::Result; 4 | use futures::Future; 5 | use lazy_static::lazy_static; 6 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 7 | use tracing::{debug, info}; 8 | 9 | lazy_static! { 10 | pub static ref CACHE_DIR: String = { 11 | let mut path = dirs::cache_dir().expect("failed to get cache dir"); 12 | path.push("ghtool"); 13 | let cache_path = path.to_str().unwrap().to_string(); 14 | info!(?path, "using cache path"); 15 | cache_path 16 | }; 17 | } 18 | 19 | #[derive(Serialize, Deserialize)] 20 | struct CacheValue { 21 | value: V, 22 | timestamp: SystemTime, 23 | } 24 | 25 | // The db needs to be opened in call to allow multiple processes 26 | pub fn put(key: K, value: V) -> Result<()> 27 | where 28 | K: AsRef<[u8]> + std::fmt::Debug, 29 | V: Serialize, 30 | { 31 | let db = open_db()?; 32 | let value = CacheValue { 33 | value, 34 | timestamp: SystemTime::now(), 35 | }; 36 | let bytes = serde_json::to_vec(&value)?; 37 | db.insert(&key, bytes)?; 38 | debug!(?key, "cache key set"); 39 | db.flush()?; 40 | Ok(()) 41 | } 42 | 43 | pub fn get(key: K) -> Result> 44 | where 45 | K: AsRef<[u8]> + std::fmt::Debug, 46 | V: DeserializeOwned, 47 | { 48 | let db = open_db()?; 49 | let bytes = db.get(&key)?; 50 | let value = match bytes { 51 | Some(bytes) => { 52 | debug!(?key, "found cached key"); 53 | let value: CacheValue = serde_json::from_slice(&bytes)?; 54 | Some(value.value) 55 | } 56 | None => None, 57 | }; 58 | Ok(value) 59 | } 60 | 61 | pub async fn memoize(key: K, f: F) -> Result 62 | where 63 | F: FnOnce() -> Fut, 64 | Fut: Future>, 65 | K: AsRef<[u8]> + std::fmt::Debug, 66 | V: Serialize + DeserializeOwned + Clone, 67 | { 68 | let cached = get(key.as_ref())?; 69 | match cached { 70 | Some(cached) => Ok(cached), 71 | None => { 72 | debug!(?key, "key not found in cache"); 73 | let value = f().await?; 74 | put(key, value.clone())?; 75 | Ok(value) 76 | } 77 | } 78 | } 79 | 80 | fn open_db() -> Result { 81 | let db = sled::Config::new() 82 | .path(CACHE_DIR.as_str()) 83 | .use_compression(true) 84 | .open()?; 85 | Ok(db) 86 | } 87 | -------------------------------------------------------------------------------- /ghtool/src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | #[derive(Parser, Debug)] 4 | #[command(author, version, about, long_about = None)] 5 | #[command(propagate_version = true)] 6 | #[command(arg_required_else_help = true)] 7 | #[command(color = clap::ColorChoice::Never)] 8 | pub struct Cli { 9 | #[command(subcommand)] 10 | pub command: Option, 11 | 12 | /// Print verbose output 13 | #[arg(global = true)] 14 | #[clap(long, short)] 15 | pub verbose: bool, 16 | 17 | /// Target branch; defaults to current branch 18 | #[arg(global = true)] 19 | #[clap(long, short)] 20 | pub branch: Option, 21 | } 22 | 23 | #[derive(Subcommand, Debug)] 24 | pub enum Commands { 25 | /// Get the failing tests for the current branch's pull request's checks 26 | Test { 27 | /// Output only the file paths 28 | #[clap(long, short)] 29 | files: bool, 30 | }, 31 | 32 | /// Get lint issues for the current branch's pull request's checks 33 | Lint { 34 | /// Output only the file paths 35 | #[clap(long, short)] 36 | files: bool, 37 | }, 38 | 39 | /// Get build issues for the current branch's pull request's checks 40 | Build { 41 | /// Output only the file paths 42 | #[clap(long, short)] 43 | files: bool, 44 | }, 45 | 46 | /// Wait for checks to complete and run all test, lint and build together 47 | All {}, 48 | 49 | /// Authenticate ghtool with GitHub API 50 | Login { 51 | /// Use stdin to pass a token that will be saved to system key store 52 | #[clap(long, short)] 53 | stdin: bool, 54 | }, 55 | 56 | /// Deauthenticate ghtool with GitHub API 57 | Logout {}, 58 | } 59 | -------------------------------------------------------------------------------- /ghtool/src/commands/auth/login.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use eyre::{eyre, Context, Result}; 4 | use http::StatusCode; 5 | use indicatif::ProgressBar; 6 | use tracing::info; 7 | 8 | use crate::{ 9 | github::{ 10 | AccessToken, AccessTokenResponse, CodeResponse, CurrentUser, GithubApiError, 11 | GithubAuthClient, GithubClient, 12 | }, 13 | spinner::make_spinner_style, 14 | term::{bold, prompt_for_user_to_continue, read_stdin}, 15 | token_store::{self, get_token}, 16 | }; 17 | 18 | pub async fn login(use_stdin_token: bool) -> Result<()> { 19 | // Assume hostname github.com for now 20 | let hostname = "github.com"; 21 | if let Some(current_user) = validate_existing_token(hostname).await? { 22 | println!("Already logged in as {}", bold(¤t_user.viewer.login)); 23 | println!("To log out, run {}", bold("ght logout")); 24 | return Ok(()); 25 | } 26 | 27 | let access_token = if use_stdin_token { 28 | read_stdin()? 29 | } else { 30 | acquire_token_from_github().await? 31 | }; 32 | 33 | token_store::set_token(hostname, &access_token) 34 | .map_err(|e| eyre!(e).wrap_err("Failed to store token"))?; 35 | 36 | let client = GithubClient::new(&access_token)?; 37 | let current_user = client.get_current_user().await?; 38 | 39 | println!( 40 | "Logged in to {} as {}", 41 | hostname, 42 | bold(¤t_user.viewer.login) 43 | ); 44 | Ok(()) 45 | } 46 | 47 | async fn validate_existing_token(hostname: &str) -> Result> { 48 | let token = match get_token(hostname) { 49 | Ok(t) => t, 50 | Err(keyring::Error::NoEntry) => { 51 | info!("No token stored, continuing"); 52 | return Ok(None); 53 | } 54 | Err(err) => { 55 | return Err(eyre!(err).wrap_err("Failed to get token from keyring")); 56 | } 57 | }; 58 | 59 | let client = GithubClient::new(&token)?; 60 | match client.get_current_user().await { 61 | Ok(current_user) => Ok(Some(current_user)), 62 | Err(GithubApiError::ErrorResponse(StatusCode::UNAUTHORIZED, _)) => { 63 | info!("Token is invalid, continuing"); 64 | Ok(None) 65 | } 66 | Err(err) => Err(eyre!(err).wrap_err("Failed to get current user")), 67 | } 68 | } 69 | 70 | async fn acquire_token_from_github() -> Result { 71 | let auth_client = GithubAuthClient::new()?; 72 | let code_response = auth_client 73 | .get_device_code() 74 | .await 75 | .wrap_err("Failed to get device code")?; 76 | 77 | println!( 78 | "First copy your one-time code: {}", 79 | bold(&code_response.user_code) 80 | ); 81 | 82 | prompt_for_user_to_continue("Press Enter to open github.com in your browser...")?; 83 | 84 | info!("Opening {} in browser", code_response.verification_uri); 85 | open::that(&code_response.verification_uri)?; 86 | 87 | let pb = create_progress_bar(); 88 | let token = await_authorization(&auth_client, &code_response).await?; 89 | pb.finish_and_clear(); 90 | Ok(token.access_token) 91 | } 92 | 93 | fn create_progress_bar() -> ProgressBar { 94 | let pb = ProgressBar::new_spinner(); 95 | pb.enable_steady_tick(Duration::from_millis(100)); 96 | pb.set_style(make_spinner_style()); 97 | pb.set_message("Waiting for authorization..."); 98 | pb 99 | } 100 | 101 | async fn await_authorization( 102 | auth_client: &GithubAuthClient, 103 | code_response: &CodeResponse, 104 | ) -> Result { 105 | loop { 106 | let token_response = auth_client 107 | .get_access_token(&code_response.device_code) 108 | .await?; 109 | 110 | match token_response { 111 | AccessTokenResponse::AuthorizationPending(_) => { 112 | tokio::time::sleep(Duration::from_secs(code_response.interval.into())).await; 113 | } 114 | AccessTokenResponse::AccessToken(token) => { 115 | return Ok(token); 116 | } 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /ghtool/src/commands/auth/logout.rs: -------------------------------------------------------------------------------- 1 | use crate::{term::bold, token_store}; 2 | use eyre::Result; 3 | 4 | pub fn logout() -> Result<()> { 5 | // Assume hostname github.com for now 6 | let hostname = "github.com"; 7 | token_store::delete_token(hostname)?; 8 | println!("Logged out of {} account", bold(hostname)); 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /ghtool/src/commands/auth/mod.rs: -------------------------------------------------------------------------------- 1 | mod login; 2 | mod logout; 3 | 4 | pub use login::*; 5 | pub use logout::*; 6 | -------------------------------------------------------------------------------- /ghtool/src/commands/build/mod.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use regex::Regex; 3 | 4 | use crate::repo_config::BuildConfig; 5 | use crate::repo_config::RepoConfig; 6 | 7 | use self::tsc::TscLogParser; 8 | 9 | use super::CheckError; 10 | use super::Command; 11 | use super::ConfigPattern; 12 | 13 | mod tsc; 14 | 15 | impl ConfigPattern for BuildConfig { 16 | fn job_pattern(&self) -> &Regex { 17 | &self.job_pattern 18 | } 19 | } 20 | 21 | #[derive(Clone)] 22 | pub struct BuildCommand { 23 | config: BuildConfig, 24 | } 25 | 26 | impl BuildCommand { 27 | pub fn from_repo_config(repo_config: &RepoConfig) -> Result { 28 | let build_config = repo_config 29 | .build 30 | .clone() 31 | .ok_or_else(|| eyre::eyre!("Error: no build section found in .ghtool.toml"))?; 32 | 33 | Ok(Self { 34 | config: build_config, 35 | }) 36 | } 37 | } 38 | 39 | impl Command for BuildCommand { 40 | fn name(&self) -> &'static str { 41 | "build" 42 | } 43 | 44 | fn check_error_plural(&self) -> &'static str { 45 | "build errors" 46 | } 47 | 48 | fn config(&self) -> &dyn ConfigPattern { 49 | &self.config 50 | } 51 | 52 | fn parse_log(&self, log: &str) -> Result> { 53 | TscLogParser::parse(log) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ghtool/src/commands/build/tsc.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use regex::Regex; 3 | 4 | use crate::commands::CheckError; 5 | 6 | const TIMESTAMP_PATTERN: &str = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z"; 7 | const ANSI_RESET: &str = r"\u{1b}\[0m"; 8 | 9 | lazy_static! { 10 | /// Regex to match a timestamp and single space after it 11 | static ref TIMESTAMP: Regex = Regex::new(&format!(r"{}\s", TIMESTAMP_PATTERN)).unwrap(); 12 | 13 | /// Regex to match an error line of the TypeScript compiler (tsc) log 14 | static ref TSC_ERROR_LINE: Regex = Regex::new(&format!( 15 | r"(?i){TIMESTAMP_PATTERN}\s+(?P##\[error\]).*?({ANSI_RESET})?(?P[a-zA-Z0-9._/-]*)\(\d+,\d+\):\serror\sTS\d+", 16 | // ^^^^^^^^^^^^^^^^^^ See test_extract_failing_files_3 17 | 18 | )) 19 | .unwrap(); 20 | } 21 | 22 | #[derive(PartialEq, Debug)] 23 | enum State { 24 | LookingForError, 25 | ParsingError, 26 | } 27 | 28 | #[derive(Debug)] 29 | pub struct TscLogParser { 30 | state: State, 31 | current_error: Option, 32 | all_errors: Vec, 33 | error_tag_start_col: usize, 34 | error_line_count: usize, 35 | } 36 | 37 | impl TscLogParser { 38 | pub fn new() -> Self { 39 | TscLogParser { 40 | state: State::LookingForError, 41 | current_error: None, 42 | all_errors: Vec::new(), 43 | error_tag_start_col: 0, 44 | error_line_count: 0, 45 | } 46 | } 47 | 48 | fn parse_line(&mut self, full_line: &str) -> Result<(), eyre::Error> { 49 | let line = TIMESTAMP.replace(full_line, ""); 50 | 51 | match self.state { 52 | State::LookingForError => { 53 | if let Some(caps) = TSC_ERROR_LINE.captures(full_line) { 54 | let path = caps.name("path").unwrap().as_str().to_string(); 55 | let without_error_tag = line.strip_prefix("##[error]").unwrap_or(&line); 56 | self.error_tag_start_col = caps.name("error").unwrap().start(); 57 | self.current_error = Some(CheckError { 58 | lines: vec![without_error_tag.to_string()], 59 | path, 60 | }); 61 | self.state = State::ParsingError; 62 | } 63 | } 64 | State::ParsingError => { 65 | self.error_line_count += 1; 66 | 67 | if TSC_ERROR_LINE.is_match(full_line) { 68 | self.reset_to_looking_for_errors(); 69 | self.parse_line(full_line)?; 70 | } else if full_line.chars().nth(self.error_tag_start_col) == Some(' ') { 71 | // ##[error]src/index.ts(3,21): error TS2769: No overload matches this call. 72 | // Overload 1 of 2, '(object: any, showHidden?: boolean | undefined, ... 73 | // ^ Needs to be whitespace to be parsed as current error's line 74 | self.current_error 75 | .as_mut() 76 | .unwrap() 77 | .lines 78 | .push(line.to_string()); 79 | } else if self.error_line_count == 1 { 80 | // The first line after seeing an error should either: 81 | // a) be a new error (first if condition) 82 | // b) be indented which means it's part of the error (second else if condition) 83 | // In any other case it would be some unrelated output and we want to get back 84 | // to looking for errors. See test_extract_failing_files_4. 85 | self.reset_to_looking_for_errors() 86 | } 87 | } 88 | } 89 | Ok(()) 90 | } 91 | 92 | fn reset_to_looking_for_errors(&mut self) { 93 | let current_error = std::mem::take(&mut self.current_error); 94 | self.all_errors.push(current_error.unwrap()); 95 | self.state = State::LookingForError; 96 | self.error_tag_start_col = 0; 97 | self.error_line_count = 0; 98 | } 99 | 100 | pub fn parse(log: &str) -> Result, eyre::Error> { 101 | let mut parser = TscLogParser::new(); 102 | 103 | for line in log.lines() { 104 | parser.parse_line(line)?; 105 | } 106 | 107 | if let Some(current_error) = parser.current_error.take() { 108 | parser.all_errors.push(current_error); 109 | } 110 | 111 | Ok(parser.all_errors) 112 | } 113 | } 114 | 115 | // Tests 116 | #[cfg(test)] 117 | mod tests { 118 | use super::*; 119 | use pretty_assertions::assert_eq; 120 | 121 | #[test] 122 | fn test_extract_failing_files_1() { 123 | let logs = r#" 124 | 2023-06-26T16:57:36.5365262Z ##[error]src/index.ts(3,21): error TS2769: No overload matches this call. 125 | 2023-06-26T16:57:36.5460952Z Overload 1 of 2, '(object: any, showHidden?: boolean | undefined, depth?: number | null | undefined, color?: boolean | undefined): string', gave the following error. 126 | 2023-06-26T16:57:36.5462190Z Argument of type '"test"' is not assignable to parameter of type 'boolean | undefined'. 127 | 2023-06-26T16:57:36.5465097Z ##[error]src/index.ts(10,3): error TS2322: Type 'number' is not assignable to type 'string'. 128 | 2023-06-26T16:57:36.5533457Z ##[error]Process completed with exit code 2."#; 129 | 130 | let failing_files = TscLogParser::parse(logs).unwrap(); 131 | assert_eq!( 132 | failing_files, 133 | vec![ 134 | CheckError { 135 | path: "src/index.ts".to_string(), 136 | lines: vec![ 137 | "src/index.ts(3,21): error TS2769: No overload matches this call.".to_string(), 138 | " Overload 1 of 2, '(object: any, showHidden?: boolean | undefined, depth?: number | null | undefined, color?: boolean | undefined): string', gave the following error.".to_string(), 139 | " Argument of type '\"test\"' is not assignable to parameter of type 'boolean | undefined'.".to_string(), 140 | ] 141 | }, 142 | CheckError { 143 | path: "src/index.ts".to_string(), 144 | lines: vec![ 145 | "src/index.ts(10,3): error TS2322: Type 'number' is not assignable to type 'string'.".to_string(), 146 | ] 147 | }, 148 | ] 149 | ); 150 | } 151 | 152 | #[test] 153 | fn test_extract_failing_files_2() { 154 | let logs = r#" 155 | 2023-06-26T16:57:36.5465097Z ##[error]src/index.ts(10,3): error TS2322: Type 'number' is not assignable to type 'string'. 156 | 2023-06-26T16:57:36.5365262Z ##[error]src/index.ts(3,21): error TS2769: No overload matches this call. 157 | 2023-06-26T16:57:36.5460952Z Overload 1 of 2, '(object: any, showHidden?: boolean | undefined, depth?: number | null | undefined, color?: boolean | undefined): string', gave the following error. 158 | 2023-06-26T16:57:36.5462190Z Argument of type '"test"' is not assignable to parameter of type 'boolean | undefined'."#; 159 | 160 | let failing_files = TscLogParser::parse(logs).unwrap(); 161 | assert_eq!( 162 | failing_files, 163 | vec![ 164 | CheckError { 165 | path: "src/index.ts".to_string(), 166 | lines: vec![ 167 | "src/index.ts(10,3): error TS2322: Type 'number' is not assignable to type 'string'.".to_string(), 168 | ] 169 | }, 170 | CheckError { 171 | path: "src/index.ts".to_string(), 172 | lines: vec![ 173 | "src/index.ts(3,21): error TS2769: No overload matches this call.".to_string(), 174 | " Overload 1 of 2, '(object: any, showHidden?: boolean | undefined, depth?: number | null | undefined, color?: boolean | undefined): string', gave the following error.".to_string(), 175 | " Argument of type '\"test\"' is not assignable to parameter of type 'boolean | undefined'.".to_string(), 176 | ] 177 | }, 178 | ] 179 | ); 180 | } 181 | 182 | #[test] 183 | fn test_extract_failing_files_3() { 184 | let logs = r#" 185 | 2023-06-21T14:10:03.3218056Z ##[error]@owner/package:typecheck: src/index.ts(63,7): error TS1117: An object literal cannot have multiple properties with the same name."#; 186 | 187 | let failing_files = TscLogParser::parse(logs).unwrap(); 188 | assert_eq!(failing_files, vec![ 189 | CheckError { 190 | path: "src/index.ts".to_string(), 191 | lines: vec![ 192 | "\u{1b}[32m@owner/package:typecheck: \u{1b}[0msrc/index.ts(63,7): error TS1117: An object literal cannot have multiple properties with the same name.".to_string() 193 | ], 194 | }, 195 | ]); 196 | } 197 | 198 | #[test] 199 | fn test_extract_failing_files_4() { 200 | let logs = r#" 201 | 2023-06-27T08:32:59.2543883Z ##[error]@project:typecheck: src/components/Component.spec.tsx(58,8): error TS2739: Type '{ foo: string; }' is missing the following properties from type 'Props': bar 202 | 2023-06-27T08:33:50.2166735Z @project:typecheck:  ELIFECYCLE  Command failed with exit code 1. 203 | 2023-06-27T08:33:50.2437013Z @project:typecheck: ERROR: command finished with error: command (/home/runner) pnpm run typecheck exited (1) 204 | 2023-06-27T08:33:50.3894539Z project:typecheck:  ELIFECYCLE  Command failed. 205 | 2023-06-27T08:33:50.3968735Z command (/home/runner) pnpm run typecheck exited (1) 206 | 2023-06-27T08:33:50.3983800Z 207 | 2023-06-27T08:33:50.3984922Z Tasks: 145 successful, 147 total 208 | 2023-06-27T08:33:50.3985487Z Cached: 73 cached, 147 total 209 | 2023-06-27T08:33:50.3985812Z Time: 3m7.006s"#; 210 | 211 | let failing_files = TscLogParser::parse(logs).unwrap(); 212 | assert_eq!(failing_files, vec![ 213 | CheckError { 214 | path: "src/components/Component.spec.tsx".to_string(), 215 | lines: vec![ 216 | "\u{1b}[34m@project:typecheck: \u{1b}[0msrc/components/Component.spec.tsx(58,8): error TS2739: Type '{ foo: string; }' is missing the following properties from type 'Props': bar".to_string(), 217 | ], 218 | }, 219 | ]); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /ghtool/src/commands/command.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | sync::Arc, 4 | }; 5 | 6 | use eyre::Result; 7 | use futures::future::try_join_all; 8 | use regex::Regex; 9 | use tokio::task::JoinHandle; 10 | use tracing::info; 11 | 12 | use crate::{ 13 | cli::Cli, 14 | commands::{BuildCommand, LintCommand, TestCommand}, 15 | git::Repository, 16 | github::{ 17 | fetch_check_run_logs, wait_for_pr_checks, CheckConclusionState, GithubClient, 18 | SimpleCheckRun, 19 | }, 20 | repo_config::RepoConfig, 21 | setup::get_repo_config, 22 | term::{bold, print_all_checks_green, print_check_run_header}, 23 | token_store, 24 | }; 25 | 26 | pub trait ConfigPattern { 27 | fn job_pattern(&self) -> &Regex; 28 | } 29 | 30 | #[derive(Debug, Clone, PartialEq)] 31 | pub struct CheckError { 32 | pub path: String, 33 | pub lines: Vec, 34 | } 35 | 36 | pub trait Command: Sync + Send { 37 | fn name(&self) -> &'static str; 38 | fn config(&self) -> &dyn ConfigPattern; 39 | fn check_error_plural(&self) -> &'static str; 40 | fn parse_log(&self, logs: &str) -> Result>; 41 | } 42 | 43 | fn filter_check_runs( 44 | command: &dyn Command, 45 | check_runs: &[SimpleCheckRun], 46 | ) -> (Vec, bool, bool) { 47 | let mut failed_check_runs = Vec::new(); 48 | let mut any_in_progress = false; 49 | let mut no_matching_runs = true; 50 | 51 | for run in check_runs { 52 | if command.config().job_pattern().is_match(&run.name) { 53 | no_matching_runs = false; 54 | 55 | if run.conclusion.is_none() { 56 | any_in_progress = true; 57 | } 58 | 59 | if run.conclusion == Some(CheckConclusionState::Failure) { 60 | failed_check_runs.push(run.clone()); 61 | } 62 | } 63 | } 64 | 65 | (failed_check_runs, any_in_progress, no_matching_runs) 66 | } 67 | 68 | pub async fn handle_command( 69 | command_type: CommandType, 70 | cli: &Cli, 71 | show_files_only: bool, 72 | ) -> Result<()> { 73 | let (repo_config, repo, branch) = get_repo_config(cli)?; 74 | let command = command_from_type(command_type, &repo_config)?; 75 | let token = get_token(&repo.hostname)?; 76 | let client = GithubClient::new(&token)?; 77 | let pull_request = client 78 | .get_pr_for_branch_memoized(&repo.owner, &repo.name, &branch) 79 | .await? 80 | .ok_or_else(|| eyre::eyre!("No pull request found for branch {}", bold(&branch)))?; 81 | 82 | let command_clone = command.clone(); 83 | let match_checkrun_name = 84 | move |name: &str| -> bool { command_clone.config().job_pattern().is_match(name) }; 85 | 86 | let all_check_runs = 87 | wait_for_pr_checks(&client, pull_request.id, Some(&match_checkrun_name)).await?; 88 | 89 | let (failed_check_runs, _, no_matching_runs) = filter_check_runs(&*command, &all_check_runs); 90 | info!(?failed_check_runs, "got failed check runs"); 91 | 92 | if no_matching_runs { 93 | eprintln!( 94 | "No {} jobs found matching the pattern /{}/", 95 | command.name(), 96 | command.config().job_pattern() 97 | ); 98 | return Ok(()); 99 | } 100 | 101 | if failed_check_runs.is_empty() { 102 | print_all_checks_green(); 103 | return Ok(()); 104 | } 105 | 106 | let check_run_errors = process_failed_check_runs( 107 | &client, 108 | &repo, 109 | CommandMode::Single(command.clone()), 110 | &failed_check_runs, 111 | ) 112 | .await?; 113 | 114 | let all_checks_errors = check_run_errors.into_values().collect::>(); 115 | if all_checks_errors.iter().all(|s| s.is_empty()) { 116 | eprintln!("No {} found in log output", command.check_error_plural()); 117 | return Ok(()); 118 | } 119 | 120 | if show_files_only { 121 | print_errored_files(all_checks_errors); 122 | } else { 123 | print_errors(&failed_check_runs, all_checks_errors); 124 | } 125 | 126 | Ok(()) 127 | } 128 | 129 | #[derive(Eq, Hash, PartialEq, Clone, Copy, Debug)] 130 | pub enum CommandType { 131 | Test, 132 | Lint, 133 | Build, 134 | } 135 | 136 | pub async fn handle_all_command(cli: &Cli) -> Result<()> { 137 | let (repo_config, repo, branch) = get_repo_config(cli)?; 138 | let token = get_token(&repo.hostname)?; 139 | let client = GithubClient::new(&token)?; 140 | let pull_request = client 141 | .get_pr_for_branch_memoized(&repo.owner, &repo.name, &branch) 142 | .await? 143 | .ok_or_else(|| eyre::eyre!("No pull request found for branch {}", bold(&branch)))?; 144 | 145 | let all_check_runs = wait_for_pr_checks(&client, pull_request.id, None).await?; 146 | let mut all_failed_check_runs = Vec::new(); 147 | let mut check_run_command_map: HashMap = HashMap::new(); 148 | let mut command_check_run_map: HashMap> = HashMap::new(); 149 | 150 | let command_types = [CommandType::Test, CommandType::Build, CommandType::Lint]; 151 | let commands: Result>> = command_types 152 | .iter() 153 | .map(|&command_type| Ok((command_type, command_from_type(command_type, &repo_config)?))) 154 | .collect(); 155 | let commands = commands?; 156 | 157 | for (command_type, command) in &commands { 158 | add_command_info( 159 | command.as_ref(), 160 | *command_type, 161 | &all_check_runs, 162 | &mut all_failed_check_runs, 163 | &mut check_run_command_map, 164 | &mut command_check_run_map, 165 | ); 166 | } 167 | 168 | let mut all_check_errors = process_failed_check_runs( 169 | &client, 170 | &repo, 171 | CommandMode::Multiple { 172 | command_map: commands, 173 | check_run_command_map, 174 | }, 175 | &all_failed_check_runs, 176 | ) 177 | .await?; 178 | 179 | let mut all_green = true; 180 | for command_type in &[CommandType::Test, CommandType::Build, CommandType::Lint] { 181 | let check_run_ids = command_check_run_map 182 | .remove(command_type) 183 | .unwrap_or_default(); 184 | let check_runs: Vec<_> = check_run_ids 185 | .iter() 186 | .filter_map(|&id| all_check_runs.iter().find(|&run| run.id == id).cloned()) 187 | .collect(); 188 | 189 | let mut check_errors = Vec::new(); 190 | for check_run_id in &check_run_ids { 191 | if let Some(errors) = all_check_errors.remove(check_run_id) { 192 | check_errors.push(errors); 193 | } 194 | } 195 | 196 | if check_errors.iter().all(|s| s.is_empty()) { 197 | continue; 198 | } 199 | 200 | all_green = false; 201 | print_errors(&check_runs, check_errors); 202 | } 203 | 204 | if all_green { 205 | print_all_checks_green(); 206 | } 207 | 208 | Ok(()) 209 | } 210 | 211 | fn command_from_type( 212 | command_type: CommandType, 213 | repo_config: &RepoConfig, 214 | ) -> Result> { 215 | let command: Box = match command_type { 216 | CommandType::Test => Box::new(TestCommand::from_repo_config(repo_config)?), 217 | CommandType::Build => Box::new(BuildCommand::from_repo_config(repo_config)?), 218 | CommandType::Lint => Box::new(LintCommand::from_repo_config(repo_config)?), 219 | }; 220 | Ok(Arc::from(command)) 221 | } 222 | 223 | fn print_errored_files(all_checks_errors: Vec>) { 224 | let files: HashSet = all_checks_errors 225 | .into_iter() 226 | .flat_map(|errors| errors.into_iter().map(|error| error.path)) 227 | .collect(); 228 | 229 | for file in files { 230 | println!("{}", file); 231 | } 232 | } 233 | 234 | fn print_errors(failed_check_runs: &[SimpleCheckRun], all_checks_errors: Vec>) { 235 | failed_check_runs 236 | .iter() 237 | .zip(all_checks_errors) 238 | .for_each(|(check_run, check_errors)| { 239 | print_check_run_header(check_run); 240 | 241 | check_errors 242 | .into_iter() 243 | .flat_map(|error| error.lines) 244 | .for_each(|line| println!("{}", line)); 245 | }); 246 | } 247 | 248 | type CheckRunId = u64; 249 | 250 | enum CommandMode { 251 | Single(Arc), 252 | Multiple { 253 | // Used to provide command's parse log function 254 | command_map: HashMap>, 255 | // Used to determine how check run's logs should be parsed 256 | check_run_command_map: HashMap, 257 | }, 258 | } 259 | 260 | /// Get logs for each failed check run, and parse them into a map of command type to check errors 261 | async fn process_failed_check_runs( 262 | client: &GithubClient, 263 | repo: &Repository, 264 | command_mode: CommandMode, 265 | all_failed_check_runs: &[SimpleCheckRun], 266 | ) -> Result>> { 267 | let log_map = fetch_check_run_logs(client, repo, all_failed_check_runs).await?; 268 | #[allow(clippy::type_complexity)] 269 | let mut parse_futures: Vec)>>> = Vec::new(); 270 | 271 | for (check_run_id, log_bytes) in log_map.iter() { 272 | let check_run_id = *check_run_id; 273 | let log_bytes = log_bytes.clone(); 274 | let command = match &command_mode { 275 | CommandMode::Single(single_command) => { 276 | single_command.clone() // Single mode: use the same command for all check runs 277 | } 278 | CommandMode::Multiple { 279 | command_map, 280 | check_run_command_map, 281 | } => { 282 | let command_type = check_run_command_map 283 | .get(&check_run_id) 284 | .unwrap_or_else(|| panic!("Unknown check run id: {}", check_run_id)); 285 | command_map.get(command_type).unwrap().clone() 286 | } 287 | }; 288 | 289 | let handle = tokio::task::spawn_blocking(move || { 290 | let log_str = std::str::from_utf8(&log_bytes)?; 291 | Ok((check_run_id, command.parse_log(log_str)?)) 292 | }); 293 | parse_futures.push(handle); 294 | } 295 | 296 | let results = try_join_all(parse_futures).await?; 297 | let mut check_errors_map = HashMap::new(); 298 | for result in results { 299 | let (command_type, check_errors) = result?; 300 | check_errors_map 301 | .entry(command_type) 302 | .or_insert_with(Vec::new) 303 | .extend(check_errors); 304 | } 305 | 306 | Ok(check_errors_map) 307 | } 308 | 309 | fn get_token(hostname: &str) -> Result { 310 | // In development, macOS is constantly asking for password when token store is accessed with a 311 | // new binary 312 | if let Ok(token) = std::env::var("GH_TOKEN") { 313 | return Ok(token); 314 | } 315 | 316 | token_store::get_token(hostname).map_err(|err| match err { 317 | keyring::Error::NoEntry => { 318 | eyre::eyre!( 319 | "No token found for {}. Have you logged in? Run {}", 320 | bold(hostname), 321 | bold("ghtool login") 322 | ) 323 | } 324 | err => eyre::eyre!("Failed to get token for {}: {}", hostname, err), 325 | }) 326 | } 327 | 328 | fn add_command_info( 329 | command: &dyn Command, 330 | command_type: CommandType, 331 | all_check_runs: &[SimpleCheckRun], 332 | all_failed_check_runs: &mut Vec, 333 | check_run_command_map: &mut HashMap, 334 | command_check_run_map: &mut HashMap>, 335 | ) { 336 | let (failed, _, _) = filter_check_runs(command, all_check_runs); 337 | all_failed_check_runs.extend_from_slice(&failed); 338 | 339 | for check_run in &failed { 340 | check_run_command_map.insert(check_run.id, command_type); 341 | command_check_run_map 342 | .entry(command_type) 343 | .or_default() 344 | .push(check_run.id); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /ghtool/src/commands/lint/eslint.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use regex::Regex; 3 | 4 | use crate::commands::CheckError; 5 | 6 | #[derive(PartialEq, Debug)] 7 | enum State { 8 | LookingForFile, 9 | ParsingFile, 10 | } 11 | 12 | lazy_static! { 13 | /// Regex to match a timestamp and single space after it 14 | static ref TIMESTAMP: Regex = 15 | Regex::new(r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s").unwrap(); 16 | 17 | /// Regex to match a path at the end of line 18 | static ref PATH: Regex = Regex::new( 19 | r"\s(?P/[a-zA-Z0-9._-]*/[a-zA-Z0-9./_-]*)$", 20 | ) 21 | .unwrap(); 22 | 23 | /// Regex to match eslint issue on a file line 24 | /// Example: 1:10 error Missing return type 25 | static ref ESLINT_ISSUE: Regex = Regex::new( 26 | r"\d+:\d+\s+\b(warning|error)\b", 27 | ) 28 | .unwrap(); 29 | } 30 | 31 | #[derive(Debug)] 32 | pub struct EslintLogParser { 33 | state: State, 34 | current_path: Option, 35 | all_paths: Vec, 36 | current_path_start_col: usize, 37 | seen_eslint_issue_for_current_path: bool, 38 | current_path_lines: usize, 39 | } 40 | 41 | impl EslintLogParser { 42 | pub fn new() -> Self { 43 | EslintLogParser { 44 | state: State::LookingForFile, 45 | current_path: None, 46 | all_paths: Vec::new(), 47 | current_path_start_col: 0, 48 | current_path_lines: 0, 49 | seen_eslint_issue_for_current_path: false, 50 | } 51 | } 52 | 53 | fn get_line_from_path_col(&self, line: &str) -> String { 54 | line.chars().skip(self.current_path_start_col).collect() 55 | } 56 | 57 | /// Is the line empty when disregarding timestamp 58 | fn is_empty_line(&self, line: &str) -> bool { 59 | line.chars().nth(self.current_path_start_col).is_none() 60 | } 61 | 62 | fn parse_line(&mut self, raw_line: &str) { 63 | let line_no_ansi = 64 | String::from_utf8(strip_ansi_escapes::strip(raw_line.as_bytes())).unwrap(); 65 | 66 | match self.state { 67 | State::LookingForFile => { 68 | if let Some(caps) = PATH.captures(&line_no_ansi) { 69 | self.current_path_start_col = caps.name("path").unwrap().start(); 70 | let path = self.get_line_from_path_col(&line_no_ansi); 71 | let line = TIMESTAMP.replace(raw_line, ""); 72 | self.current_path = Some(CheckError { 73 | lines: vec![line.to_string()], 74 | path, 75 | }); 76 | self.state = State::ParsingFile; 77 | } 78 | } 79 | State::ParsingFile => { 80 | self.current_path_lines += 1; 81 | 82 | if ESLINT_ISSUE.is_match(&line_no_ansi) { 83 | let line = TIMESTAMP.replace(raw_line, "").to_string(); 84 | let line = line.strip_prefix("##[error]").unwrap_or(&line); 85 | let line = line.strip_prefix("##[warning]").unwrap_or(line); 86 | self.current_path 87 | .as_mut() 88 | .unwrap() 89 | .lines 90 | .push(line.to_string()); 91 | self.seen_eslint_issue_for_current_path = true; 92 | } else if self.current_path_lines == 1 { 93 | // If the line directly under path does not match ESLINT_ISSUE, reset back to 94 | // looking for file. In certain cases this avoids the problem of never getting 95 | // back to looking for file state because some path is matched early in the 96 | // logs. 97 | self.state = State::LookingForFile; 98 | self.seen_eslint_issue_for_current_path = false; 99 | self.current_path_lines = 0; 100 | } else if self.is_empty_line(&line_no_ansi) { 101 | // Empty line after starting to "parse a file" means the file will change 102 | // 103 | // Example: 104 | // 2023-06-14T20:22:39.1727281Z /root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts 105 | // 2023-06-14T20:22:39.1789066Z ##[warning] 1:42 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 106 | // 2023-06-14T20:22:39.1790470Z [empty line] 107 | // 2023-06-14T20:22:39.1790995Z /root_path/project_directory/module_2/setupModule2Test.ts 108 | // 2023-06-14T20:22:39.1792493Z ##[warning] 166:58 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 109 | self.state = State::LookingForFile; 110 | 111 | if self.seen_eslint_issue_for_current_path { 112 | let current_eslint_path = std::mem::take(&mut self.current_path); 113 | self.all_paths.push(current_eslint_path.unwrap()); 114 | } else { 115 | self.current_path = None; 116 | } 117 | 118 | self.seen_eslint_issue_for_current_path = false; 119 | self.current_path_lines = 0; 120 | } 121 | } 122 | } 123 | } 124 | 125 | pub fn parse(log: &str) -> Vec { 126 | let mut parser = EslintLogParser::new(); 127 | 128 | for line in log.lines() { 129 | parser.parse_line(line); 130 | } 131 | 132 | parser.get_output() 133 | } 134 | 135 | pub fn get_output(self) -> Vec { 136 | self.all_paths 137 | } 138 | } 139 | 140 | #[cfg(test)] 141 | mod tests { 142 | use crate::commands::CheckError; 143 | 144 | use super::*; 145 | use pretty_assertions::assert_eq; 146 | 147 | #[test] 148 | fn test_parse_basic() { 149 | let log: &str = r#" 150 | 2023-06-14T20:10:57.9100220Z > project@0.0.1 lint:base 151 | 2023-06-14T20:10:57.9102305Z > eslint --ext .ts --ignore-pattern "node_modules" --ignore-pattern "coverage" --ignore-pattern "**/*.js" src test 152 | 2023-06-14T20:10:57.9102943Z 153 | 2023-06-14T20:22:39.1725170Z 154 | 2023-06-14T20:22:39.1727281Z /root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts 155 | 2023-06-14T20:22:39.1789066Z ##[warning] 1:42 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 156 | 2023-06-14T20:22:39.1790470Z 157 | 2023-06-14T20:22:39.1790995Z /root_path/project_directory/module_2/setupModule2Test.ts 158 | 2023-06-14T20:22:39.1792493Z ##[warning] 166:58 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 159 | 2023-06-14T20:22:39.1794354Z ##[warning] 309:55 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 160 | 2023-06-14T20:22:39.1795885Z ##[warning] 470:55 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 161 | 2023-06-14T20:22:39.1796538Z 162 | 2023-06-14T20:22:39.1796973Z /root_path/project_directory/module_3/getSpecificUploadImageResponse.ts 163 | 2023-06-14T20:22:39.1798218Z ##[warning] 4:47 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 164 | 2023-06-14T20:22:39.1815738Z 165 | 2023-06-14T20:22:39.1816392Z /root_path/project_directory/module_4/submodule_2/setupInitialDB.ts 166 | 2023-06-14T20:22:39.1818449Z ##[error] 1:1 error Delete `import·*·as·fs·from·'fs';⏎` prettier/prettier 167 | 2023-06-14T20:22:39.1819948Z ##[error] 1:13 error 'fs' is defined but never used @typescript-eslint/no-unused-vars 168 | 2023-06-14T20:22:39.2063811Z 169 | 2023-06-14T20:22:39.2063811Z ✖ 132 problems (4 errors, 128 warnings) 170 | 2023-06-14T20:22:39.2064409Z 2 errors and 0 warnings potentially fixable with the `--fix` option."#; 171 | 172 | let output = EslintLogParser::parse(log); 173 | assert_eq!( 174 | output, 175 | vec![ 176 | CheckError { 177 | path: "/root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts".to_string(), 178 | lines: vec![ 179 | "/root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts".to_string(), 180 | " 1:42 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 181 | .to_string(), 182 | ], 183 | }, 184 | CheckError { 185 | path: "/root_path/project_directory/module_2/setupModule2Test.ts".to_string(), 186 | lines: vec![ 187 | "/root_path/project_directory/module_2/setupModule2Test.ts".to_string(), 188 | " 166:58 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 189 | .to_string(), 190 | " 309:55 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 191 | .to_string(), 192 | " 470:55 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 193 | .to_string(), 194 | ], 195 | }, 196 | CheckError { 197 | path: "/root_path/project_directory/module_3/getSpecificUploadImageResponse.ts".to_string(), 198 | lines: vec![ 199 | "/root_path/project_directory/module_3/getSpecificUploadImageResponse.ts".to_string(), 200 | " 4:47 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 201 | .to_string(), 202 | ], 203 | }, 204 | CheckError { 205 | path: "/root_path/project_directory/module_4/submodule_2/setupInitialDB.ts".to_string(), 206 | lines: vec![ 207 | "/root_path/project_directory/module_4/submodule_2/setupInitialDB.ts".to_string(), 208 | " 1:1 error Delete `import·*·as·fs·from·'fs';⏎` prettier/prettier" 209 | .to_string(), 210 | " 1:13 error 'fs' is defined but never used @typescript-eslint/no-unused-vars" 211 | .to_string(), 212 | ], 213 | }, 214 | ] 215 | ); 216 | } 217 | 218 | #[test] 219 | fn test_parse_corner_case() { 220 | let log = r#" 221 | 2023-06-14T20:10:38.3206108Z ##[debug]Cleaning runner temp folder: /home/runner/work/_temp 222 | 2023-06-14T20:10:38.3472682Z ##[debug]Starting: Set up job 223 | 2023-06-14T20:10:41.2671897Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/test/test 224 | 2023-06-14T20:10:41.2671897Z 225 | 2023-06-14T20:22:39.1727281Z /root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts 226 | 2023-06-14T20:22:39.1789066Z ##[warning] 1:42 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types 227 | 2023-06-14T20:10:41.2671897Z 228 | "#; 229 | let output = EslintLogParser::parse(log); 230 | assert_eq!( 231 | output, 232 | vec![ 233 | CheckError { 234 | path: "/root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts".to_string(), 235 | lines: vec![ 236 | "/root_path/project_directory/module_1/submodule_1/fixtures/data/file_1.ts".to_string(), 237 | " 1:42 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types" 238 | .to_string(), 239 | ], 240 | }, 241 | 242 | ] 243 | 244 | ); 245 | } 246 | 247 | #[test] 248 | fn test_parse_ansi_monorepo() { 249 | let log: &str = r#" 250 | 2023-06-16T15:54:54.4381752Z @project/package:lint: > @project/package@x.y.z lint:eslint /path/to/working/directory 251 | 2023-06-16T15:54:54.4383282Z @project/package:lint: > eslint -c .eslintrc.js . 252 | 2023-06-16T15:54:54.4385037Z @project/package:lint:  253 | 2023-06-16T15:54:54.4386084Z @project/package:lint:  254 | 2023-06-16T15:54:54.4387931Z @project/package:lint: /path/to/working/directory/src/components/ComponentWrapper.spec.tsx 255 | 2023-06-16T15:54:54.4389816Z @project/package:lint:  8:1 warning Disabled test suite jest/no-disabled-tests 256 | 2023-06-16T15:54:54.4391533Z @project/package:lint:  41:7 warning Disabled test jest/no-disabled-tests 257 | 2023-06-16T15:54:54.4393248Z @project/package:lint:  59:7 warning Disabled test jest/no-disabled-tests 258 | 2023-06-16T15:54:54.4394749Z @project/package:lint:  259 | 2023-06-16T15:54:54.4396497Z @project/package:lint: /path/to/working/directory/src/hooks/useCustomHook.spec.ts 260 | 2023-06-16T15:54:54.4398548Z @project/package:lint:  6:46 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 261 | 2023-06-16T15:54:54.4400116Z @project/package:lint:  262 | 2023-06-16T15:54:54.4401725Z @project/package:lint: ✖ 4 problems (0 errors, 4 warnings) 263 | 2023-06-14T20:22:39.2063811Z ✖ 132 problems (4 errors, 128 warnings)"#; 264 | 265 | let output = EslintLogParser::parse(log); 266 | assert_eq!(output, vec![ 267 | CheckError { 268 | path: "/path/to/working/directory/src/components/ComponentWrapper.spec.tsx".to_string(), 269 | lines: vec![ 270 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m\u{1b}[4m/path/to/working/directory/src/components/ComponentWrapper.spec.tsx\u{1b}[24m\u{1b}[0m".to_string(), 271 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m \u{1b}[2m8:1\u{1b}[22m \u{1b}[33mwarning\u{1b}[39m Disabled test suite \u{1b}[2mjest/no-disabled-tests\u{1b}[22m\u{1b}[0m".to_string(), 272 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m \u{1b}[2m41:7\u{1b}[22m \u{1b}[33mwarning\u{1b}[39m Disabled test \u{1b}[2mjest/no-disabled-tests\u{1b}[22m\u{1b}[0m".to_string(), 273 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m \u{1b}[2m59:7\u{1b}[22m \u{1b}[33mwarning\u{1b}[39m Disabled test \u{1b}[2mjest/no-disabled-tests\u{1b}[22m\u{1b}[0m".to_string() 274 | ], 275 | }, 276 | CheckError { 277 | path: "/path/to/working/directory/src/hooks/useCustomHook.spec.ts".to_string(), 278 | lines: vec![ 279 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m\u{1b}[4m/path/to/working/directory/src/hooks/useCustomHook.spec.ts\u{1b}[24m\u{1b}[0m".to_string(), 280 | "\u{1b}[34m@project/package:lint: \u{1b}[0m\u{1b}[0m \u{1b}[2m6:46\u{1b}[22m \u{1b}[33mwarning\u{1b}[39m Unexpected any. Specify a different type \u{1b}[2m@typescript-eslint/no-explicit-any\u{1b}[22m\u{1b}[0m".to_string() 281 | ], 282 | }, 283 | ]); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /ghtool/src/commands/lint/mod.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use regex::Regex; 3 | 4 | use crate::repo_config::LintConfig; 5 | use crate::repo_config::RepoConfig; 6 | 7 | use self::eslint::EslintLogParser; 8 | 9 | use super::CheckError; 10 | use super::Command; 11 | use super::ConfigPattern; 12 | 13 | mod eslint; 14 | 15 | impl ConfigPattern for LintConfig { 16 | fn job_pattern(&self) -> &Regex { 17 | &self.job_pattern 18 | } 19 | } 20 | 21 | #[derive(Clone)] 22 | pub struct LintCommand { 23 | config: LintConfig, 24 | } 25 | 26 | impl LintCommand { 27 | pub fn from_repo_config(repo_config: &RepoConfig) -> Result { 28 | let lint_config = repo_config 29 | .lint 30 | .clone() 31 | .ok_or_else(|| eyre::eyre!("Error: no lint section found in .ghtool.toml"))?; 32 | 33 | Ok(Self { 34 | config: lint_config, 35 | }) 36 | } 37 | } 38 | 39 | impl Command for LintCommand { 40 | fn name(&self) -> &'static str { 41 | "lint" 42 | } 43 | 44 | fn check_error_plural(&self) -> &'static str { 45 | "lint issues" 46 | } 47 | 48 | fn config(&self) -> &dyn ConfigPattern { 49 | &self.config 50 | } 51 | 52 | fn parse_log(&self, log: &str) -> Result> { 53 | Ok(EslintLogParser::parse(log)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ghtool/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | 3 | mod build; 4 | mod command; 5 | mod lint; 6 | mod test; 7 | 8 | pub use build::*; 9 | pub use command::*; 10 | pub use lint::*; 11 | pub use test::*; 12 | -------------------------------------------------------------------------------- /ghtool/src/commands/test/jest.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::command::CheckError; 2 | use eyre::Result; 3 | use lazy_static::lazy_static; 4 | use regex::Regex; 5 | 6 | const TIMESTAMP_PATTERN: &str = r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)"; 7 | 8 | lazy_static! { 9 | /// Regex to match a timestamp and single space after it 10 | static ref TIMESTAMP: Regex = Regex::new(&format!(r"{TIMESTAMP_PATTERN}\s?")).unwrap(); 11 | static ref JEST_FAIL_LINE: Regex = 12 | Regex::new(r"(?PFAIL)\s+(?P[a-zA-Z0-9._-]*/[a-zA-Z0-9./_-]*)").unwrap(); 13 | static ref ESCAPE_SEQUENCE: Regex = Regex::new(r"\x1B\[\d+(;\d+)*m").unwrap(); 14 | static ref FAIL_START: Regex = Regex::new(r"(\x1B\[\d+(;\d+)*m)+\s?FAIL").unwrap(); 15 | } 16 | 17 | fn find_fail_start(log: &str) -> Option { 18 | // First handle test_jest_in_docker case: ... |^[[0m FAIL src/b.test.ts 19 | // In this case, we should get the position where FAIL starts 20 | // Otherwise try to find left most escape sequence position before FAIL 21 | log.find("\u{1b}[0m FAIL") 22 | .and_then(|_| log.find("FAIL")) 23 | .or_else(|| FAIL_START.find(log).map(|m| m.start())) 24 | .or_else(|| log.find("FAIL")) 25 | } 26 | 27 | #[derive(Debug, Clone, PartialEq)] 28 | pub struct JestPath { 29 | pub path: String, 30 | pub lines: Vec, 31 | } 32 | 33 | #[derive(PartialEq, Debug)] 34 | enum State { 35 | LookingForFail, 36 | ParsingFail, 37 | } 38 | 39 | #[derive(Debug)] 40 | pub struct JestLogParser { 41 | state: State, 42 | current_fail: Option, 43 | all_fails: Vec, 44 | current_fail_start_col: usize, 45 | current_fail_lines: Vec, 46 | } 47 | 48 | impl JestLogParser { 49 | pub fn new() -> Self { 50 | JestLogParser { 51 | state: State::LookingForFail, 52 | current_fail: None, 53 | all_fails: Vec::new(), 54 | current_fail_start_col: 0, 55 | current_fail_lines: Vec::new(), 56 | } 57 | } 58 | 59 | fn parse_line(&mut self, raw_line: &str) -> Result<(), eyre::Error> { 60 | let line_no_ansi = String::from_utf8(strip_ansi_escapes::strip(raw_line.as_bytes()))?; 61 | let line_no_timestamp = TIMESTAMP.replace(raw_line, ""); 62 | 63 | match self.state { 64 | State::LookingForFail => { 65 | if let Some(caps) = JEST_FAIL_LINE.captures(&line_no_ansi) { 66 | // Attempt to find the column where the colored FAIL text starts. 67 | // This column position will be used to determine where jest output starts. 68 | // We can't just take everything after timestamp because there's possibility 69 | // that jest is running inside docker-compose in which case there would be 70 | // service name after timestamp. 71 | // https://github.com/raine/ghtool/assets/11027/c349807a-cad1-45cb-b02f-4d5020bb3c23 72 | self.current_fail_start_col = find_fail_start(&line_no_timestamp).unwrap(); 73 | let path = caps.name("path").unwrap().as_str().to_string(); 74 | // Get line discarding things before the column where FAIL starts 75 | let line = line_no_timestamp 76 | .chars() 77 | .skip(self.current_fail_start_col) 78 | .collect::(); 79 | self.current_fail = Some(CheckError { 80 | lines: vec![line.to_string()], 81 | path, 82 | }); 83 | self.state = State::ParsingFail; 84 | } 85 | } 86 | State::ParsingFail => { 87 | let next_char_from_fail = 88 | find_next_non_ansi_char(&line_no_timestamp, self.current_fail_start_col); 89 | 90 | // https://github.com/raine/ghtool/assets/11027/08dd631e-391c-4277-8eab-75fe55d9e659 91 | if line_no_timestamp.len() > self.current_fail_start_col 92 | && next_char_from_fail.is_some() 93 | && next_char_from_fail != Some(' ') 94 | { 95 | let mut current_fail = std::mem::take(&mut self.current_fail).unwrap(); 96 | 97 | // Remove trailing empty lines 98 | if let Some(last_non_empty_line) = 99 | current_fail.lines.iter().rposition(|line| !line.is_empty()) 100 | { 101 | current_fail.lines.truncate(last_non_empty_line + 1); 102 | } 103 | 104 | self.all_fails.push(current_fail); 105 | self.current_fail_lines = Vec::new(); 106 | self.state = State::LookingForFail; 107 | } else { 108 | // Get line discarding things before the column where FAIL starts 109 | let line = line_no_timestamp 110 | .chars() 111 | .skip(self.current_fail_start_col) 112 | .collect::(); 113 | 114 | self.current_fail.as_mut().unwrap().lines.push(line); 115 | } 116 | } 117 | } 118 | Ok(()) 119 | } 120 | 121 | pub fn parse(log: &str) -> Result> { 122 | let mut parser = JestLogParser::new(); 123 | 124 | for line in log.lines() { 125 | parser.parse_line(line)?; 126 | } 127 | 128 | Ok(parser.get_output()) 129 | } 130 | 131 | pub fn get_output(self) -> Vec { 132 | self.all_fails 133 | .into_iter() 134 | .fold(Vec::new(), |mut acc, fail| { 135 | if !acc.contains(&fail) { 136 | acc.push(fail); 137 | } 138 | acc 139 | }) 140 | } 141 | } 142 | 143 | impl Default for JestLogParser { 144 | fn default() -> Self { 145 | Self::new() 146 | } 147 | } 148 | 149 | fn find_next_non_ansi_char(str: &str, start_col: usize) -> Option { 150 | let bytes = str.as_bytes(); 151 | let mut index = start_col; 152 | 153 | while index < bytes.len() { 154 | if bytes[index] == 0x1B { 155 | // found an ESC character, start skipping the ANSI sequence 156 | index += 1; // skip the ESC character 157 | if index < bytes.len() && bytes[index] == b'[' { 158 | index += 1; // skip the '[' character 159 | // skip until we find a letter indicating the end of the ANSI sequence 160 | while index < bytes.len() && !bytes[index].is_ascii_alphabetic() { 161 | index += 1; 162 | } 163 | } 164 | } else { 165 | // found a non-ANSI escape character 166 | return str[index..].chars().next(); 167 | } 168 | index += 1; 169 | } 170 | 171 | None 172 | } 173 | 174 | // Tests 175 | #[cfg(test)] 176 | mod tests { 177 | use super::*; 178 | use pretty_assertions::assert_eq; 179 | 180 | #[test] 181 | fn test_extract_failing_tests() { 182 | let logs = r#" 183 | 2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent.test.tsx 184 | 2021-05-04T18:24:29.000Z ● Test suite failed to run 185 | 2021-05-04T18:24:29.000Z TypeError: Cannot read property 'foo' of undefined 186 | 2021-05-04T18:24:29.000Z 187 | 2021-05-04T18:24:29.000Z 1 | import React from 'react'; 188 | 2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent.test.tsx 189 | 2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent2.test.tsx 190 | 2021-05-04T18:24:29.000Z ● Test suite failed to run 191 | 2021-05-04T18:24:29.000Z TypeError: Cannot read property 'foo' of undefined 192 | 2021-05-04T18:24:29.000Z 193 | 2021-05-04T18:24:29.000Z 1 | import React from 'react'; 194 | 2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent2.test.tsx"#; 195 | 196 | let failing_tests = JestLogParser::parse(logs).unwrap(); 197 | assert_eq!( 198 | failing_tests, 199 | vec![ 200 | CheckError { 201 | path: "src/components/MyComponent/MyComponent.test.tsx".to_string(), 202 | lines: vec![ 203 | "FAIL src/components/MyComponent/MyComponent.test.tsx".to_string(), 204 | " ● Test suite failed to run".to_string(), 205 | " TypeError: Cannot read property 'foo' of undefined".to_string(), 206 | "".to_string(), 207 | " 1 | import React from 'react';".to_string(), 208 | ] 209 | }, 210 | CheckError { 211 | path: "src/components/MyComponent/MyComponent2.test.tsx".to_string(), 212 | lines: vec![ 213 | "FAIL src/components/MyComponent/MyComponent2.test.tsx".to_string(), 214 | " ● Test suite failed to run".to_string(), 215 | " TypeError: Cannot read property 'foo' of undefined".to_string(), 216 | "".to_string(), 217 | " 1 | import React from 'react';".to_string(), 218 | ] 219 | }, 220 | ] 221 | ); 222 | } 223 | 224 | #[test] 225 | fn test_extract_failing_tests_2() { 226 | let logs = r#" 227 | 2023-06-28T21:11:38.9421220Z > ghtool-test-repo@1.0.0 test 228 | 2023-06-28T21:11:38.9428514Z > jest ./src --color --ci --shard=1/2 229 | 2023-06-28T21:11:38.9429089Z 230 | 2023-06-28T21:11:43.1619050Z FAIL src/test2.test.ts 231 | 2023-06-28T21:11:43.1623893Z test2 232 | 2023-06-28T21:11:43.1629746Z ✓ succeeds (3 ms) 233 | 2023-06-28T21:11:43.1630396Z ✕ fails (5 ms) 234 | 2023-06-28T21:11:43.1630949Z 235 | 2023-06-28T21:11:43.1631448Z ● test2 › fails 236 | 2023-06-28T21:11:43.1631750Z 237 | 2023-06-28T21:11:43.1632455Z expect(received).toBe(expected) // Object.is equality 238 | 2023-06-28T21:11:43.1633081Z 239 | 2023-06-28T21:11:43.1633381Z Expected: false 240 | 2023-06-28T21:11:43.1633800Z Received: true 241 | 2023-06-28T21:11:43.1634250Z 242 | 2023-06-28T21:11:43.1634753Z 5 | 243 | 2023-06-28T21:11:43.1635444Z 6 | it("fails", () => { 244 | 2023-06-28T21:11:43.1636318Z > 7 | expect(true).toBe(false); 245 | 2023-06-28T21:11:43.1637060Z | ^ 246 | 2023-06-28T21:11:43.1642719Z 8 | }); 247 | 2023-06-28T21:11:43.1647216Z 9 | }); 248 | 2023-06-28T21:11:43.1648590Z 10 | 249 | 2023-06-28T21:11:43.1649650Z 250 | 2023-06-28T21:11:43.1651496Z at Object. (src/test2.test.ts:7:18) 251 | 2023-06-28T21:11:43.1652032Z 252 | 2023-06-28T21:11:43.1664383Z Test Suites: 1 failed, 1 total 253 | 2023-06-28T21:11:43.1665139Z Tests: 1 failed, 1 passed, 2 total 254 | 2023-06-28T21:11:43.1665683Z Snapshots: 0 total 255 | 2023-06-28T21:11:43.1666152Z Time: 3.464 s 256 | 2023-06-28T21:11:43.1666769Z Ran all test suites matching /.\/src/i."#; 257 | 258 | let failing_tests = JestLogParser::parse(logs).unwrap(); 259 | assert_eq!( 260 | failing_tests, 261 | vec![CheckError { 262 | path: "src/test2.test.ts".to_string(), 263 | lines: vec![ 264 | "FAIL src/test2.test.ts".to_string(), 265 | " test2".to_string(), 266 | " ✓ succeeds (3 ms)".to_string(), 267 | " ✕ fails (5 ms)".to_string(), 268 | "".to_string(), 269 | " ● test2 › fails".to_string(), 270 | "".to_string(), 271 | " expect(received).toBe(expected) // Object.is equality".to_string(), 272 | "".to_string(), 273 | " Expected: false".to_string(), 274 | " Received: true".to_string(), 275 | "".to_string(), 276 | " 5 |".to_string(), 277 | " 6 | it(\"fails\", () => {".to_string(), 278 | " > 7 | expect(true).toBe(false);".to_string(), 279 | " | ^".to_string(), 280 | " 8 | });".to_string(), 281 | " 9 | });".to_string(), 282 | " 10 |".to_string(), 283 | "".to_string(), 284 | " at Object. (src/test2.test.ts:7:18)".to_string(), 285 | ], 286 | },] 287 | ); 288 | } 289 | 290 | #[test] 291 | fn test_extract_failing_test_files() { 292 | let logs = r#" 293 | 2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent.test.tsx 294 | 2021-05-04T18:24:29.000Z ● Test suite failed to run 295 | 2021-05-04T18:24:29.000Z TypeError: Cannot read property 'foo' of undefined 296 | 2021-05-04T18:24:29.000Z 297 | 2021-05-04T18:24:29.000Z 1 | import React from 'react'; 298 | 2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent2.test.tsx 299 | 2021-05-04T18:24:29.000Z FAIL src/components/MyComponent/MyComponent3.test.tsx 300 | 2021-05-04T18:24:29.000Z ● Test suite failed to run 301 | 2021-05-04T18:24:29.000Z TypeError: Cannot read property 'foo' of undefined 302 | 2021-05-04T18:24:29.000Z 303 | 2021-05-04T18:24:29.000Z 1 | import React from 'react'; 304 | 2021-05-04T18:24:29.000Z PASS src/components/MyComponent/MyComponent4.test.tsx"#; 305 | 306 | let failing_tests = JestLogParser::parse(logs).unwrap(); 307 | let failing_test_files: Vec = failing_tests 308 | .iter() 309 | .map(|jest_path| jest_path.path.clone()) 310 | .collect(); 311 | 312 | assert_eq!( 313 | failing_test_files, 314 | vec![ 315 | "src/components/MyComponent/MyComponent.test.tsx".to_string(), 316 | "src/components/MyComponent/MyComponent3.test.tsx".to_string(), 317 | ] 318 | ); 319 | } 320 | 321 | #[test] 322 | fn test_remove_duplicate_check_errors() { 323 | let logs = r#" 324 | 2023-09-14T12:22:30.2648458Z 325 | 2023-09-14T12:22:30.2648458Z FAIL src/components/MyComponent/MyComponent3.test.tsx 326 | 2023-09-14T12:22:30.2648458Z ● Test suite failed to run 327 | 2023-09-14T12:22:30.2648458Z TypeError: Cannot read property 'foo' of undefined 328 | 2023-09-14T12:22:30.2648458Z 329 | 2023-09-14T12:22:30.2648458Z 1 | import React from 'react'; 330 | 2023-09-14T12:22:30.2648458Z 331 | 2023-09-14T12:22:30.2649146Z Summary of all failing tests 332 | 2023-09-14T12:22:30.2648458Z FAIL src/components/MyComponent/MyComponent3.test.tsx 333 | 2023-09-14T12:22:30.2648458Z ● Test suite failed to run 334 | 2023-09-14T12:22:30.2648458Z TypeError: Cannot read property 'foo' of undefined 335 | 2023-09-14T12:22:30.2648458Z 336 | 2023-09-14T12:22:30.2648458Z 1 | import React from 'react'; 337 | 2023-09-14T12:22:30.2673693Z 338 | 2023-09-14T12:22:30.2673711Z 339 | 2023-09-14T12:22:30.2678119Z Test Suites: 1 failed, 67 passed, 68 total 340 | 2023-09-14T12:22:30.2679079Z Tests: 1 failed, 469 passed, 470 total 341 | 2023-09-14T12:22:30.2680281Z Snapshots: 60 passed, 60 total 342 | 2023-09-14T12:22:30.2680933Z Time: 216.339 s 343 | "#; 344 | 345 | let failing_tests = JestLogParser::parse(logs).unwrap(); 346 | assert_eq!(failing_tests.len(), 1); 347 | } 348 | 349 | #[test] 350 | fn test_jest_in_docker() { 351 | let logs = r#" 352 | 2023-12-14T12:24:25.7014935Z test_1 | $ jest -c jest.config.test.js 353 | 2023-12-14T12:24:43.7723478Z test_1 | PASS src/a.test.ts (16.764 s) 354 | 2023-12-14T12:24:53.1189316Z test_1 | FAIL src/b.test.ts 355 | 2023-12-14T12:24:53.1486488Z test_1 | ● test › return test things 356 | 2023-12-14T12:24:53.1488314Z test_1 | 357 | 2023-12-14T12:24:53.1489247Z test_1 | expect(received).toMatchObject(expected) 358 | 2023-12-14T12:24:53.1490238Z test_1 | 359 | 2023-12-14T12:24:53.1490994Z test_1 | - Expected - 1 360 | 2023-12-14T12:24:53.1491871Z test_1 | + Received + 0 361 | 2023-12-14T12:24:53.1492657Z test_1 | 362 | 2023-12-14T12:24:53.1493405Z test_1 | @@ -17,9 +17,8 @@ 363 | 2023-12-14T12:24:53.1662308Z test_1 | - "testId": undefined, 364 | 2023-12-14T12:24:53.1684564Z test_1 | }, 365 | 2023-12-14T12:24:53.1724498Z test_1 | }, 366 | 2023-12-14T12:24:53.1764019Z test_1 | ] 367 | 2023-12-14T12:24:53.1788159Z test_1 | 368 | 2023-12-14T12:24:53.1790147Z test_1 | > 62 | expect(result).toMatchObject([ 369 | 2023-12-14T12:24:53.1790859Z test_1 | | ^ 370 | 2023-12-14T12:24:53.1794182Z test_1 | 371 | 2023-12-14T12:24:53.1794946Z test_1 | at Object. (src/a.test.ts:62:20) 372 | 2023-12-14T12:24:53.1841737Z test_1 | 373 | 2023-12-14T12:24:53.4683252Z test_1 | PASS src/b.test.ts 374 | "#; 375 | 376 | let failing_tests = JestLogParser::parse(logs).unwrap(); 377 | 378 | assert_eq!( 379 | failing_tests, 380 | vec![CheckError { 381 | path: "src/b.test.ts".to_string(), 382 | lines: vec![ 383 | "FAIL src/b.test.ts".to_string(), 384 | " ● test › return test things".to_string(), 385 | "".to_string(), 386 | " expect(received).toMatchObject(expected)".to_string(), 387 | "".to_string(), 388 | " - Expected - 1".to_string(), 389 | " + Received + 0".to_string(), 390 | "".to_string(), 391 | " @@ -17,9 +17,8 @@".to_string(), 392 | " - \"testId\": undefined,".to_string(), 393 | " },".to_string(), 394 | " },".to_string(), 395 | " ]".to_string(), 396 | "".to_string(), 397 | " > 62 | expect(result).toMatchObject([".to_string(), 398 | " | ^".to_string(), 399 | "".to_string(), 400 | " at Object. (src/a.test.ts:62:20)".to_string(), 401 | ], 402 | }] 403 | ); 404 | } 405 | 406 | #[test] 407 | fn test_find_fail_position() { 408 | let test_cases = vec![ 409 | ( 410 | "2023-12-14T12:24:53.1189316Z test_1 | FAIL src/b.test.ts", 411 | Some(58), 412 | ), 413 | ( 414 | "2024-05-11T20:45:16.0032874Z  FAIL  src/test2.test.ts (61.458 s)", 415 | Some(29), 416 | ), 417 | ( 418 | "2024-05-29T08:34:09.8655201Z FAIL src/a.spec.tsx (14728 ms)", 419 | Some(31), 420 | ), 421 | ( 422 | "2024-05-11T20:45:16.0032874Z  FAIL  src/test2.test.ts (61.458 s)", 423 | Some(29), 424 | ), 425 | ]; 426 | 427 | for (input, expected) in test_cases { 428 | assert_eq!(find_fail_start(input), expected); 429 | } 430 | } 431 | 432 | #[test] 433 | fn test_escape_sequence() { 434 | assert!(ESCAPE_SEQUENCE.is_match("")); 435 | } 436 | 437 | #[test] 438 | fn test_colors() { 439 | let logs = r#" 440 | 2024-05-11T20:44:13.9945728Z $ jest ./src --color --ci --shard=1/2 441 | 2024-05-11T20:45:16.0032874Z  FAIL  src/test2.test.ts (61.458 s) 442 | 2024-05-11T20:45:16.0034300Z test2 443 | 2024-05-11T20:45:16.0037347Z ✓ succeeds (1 ms) 444 | 2024-05-11T20:45:16.0038258Z ✕ fails (2 ms) 445 | 2024-05-11T20:45:16.0039034Z ✓ foo (60001 ms) 446 | 2024-05-11T20:45:16.0039463Z 447 | 2024-05-11T20:45:16.0039981Z  ● test2 › fails 448 | 2024-05-11T20:45:16.0040506Z 449 | 2024-05-11T20:45:16.0041462Z expect(received).toBe(expected) // Object.is equality 450 | 2024-05-11T20:45:16.0045857Z 451 | 2024-05-11T20:45:16.0046210Z Expected: false 452 | 2024-05-11T20:45:16.0046774Z Received: true 453 | 2024-05-11T20:45:16.0047256Z  454 | 2024-05-11T20:45:16.0047765Z    5 | 455 | 2024-05-11T20:45:16.0048791Z    6 | it("fails", () => { 456 | 2024-05-11T20:45:16.0051048Z  > 7 | expect(true).toBe(false); 457 | 2024-05-11T20:45:16.0052427Z    | ^ 458 | 2024-05-11T20:45:16.0053352Z    8 | }); 459 | 2024-05-11T20:45:16.0054060Z    9 | 460 | 2024-05-11T20:45:16.0055164Z    10 | it("foo", async () => { 461 | 2024-05-11T20:45:16.0056008Z  462 | 2024-05-11T20:45:16.0057064Z  at Object. (src/test2.test.ts:7:18) 463 | 2024-05-11T20:45:16.0057817Z 464 | 2024-05-11T20:45:16.0064933Z Test Suites: 1 failed, 1 total 465 | 2024-05-11T20:45:16.0065943Z Tests: 1 failed, 2 passed, 3 total 466 | 2024-05-11T20:45:16.0066489Z Snapshots: 0 total 467 | 2024-05-11T20:45:16.0066847Z Time: 61.502 s 468 | 2024-05-11T20:45:16.0067359Z Ran all test suites matching /.\/src/i. 469 | "#; 470 | 471 | let failing_tests = JestLogParser::parse(logs).unwrap(); 472 | assert_eq!( 473 | failing_tests, 474 | vec![CheckError { 475 | path: "src/test2.test.ts".to_string(), 476 | lines: vec![ 477 | "\u{1b}[0m\u{1b}[7m\u{1b}[1m\u{1b}[31m FAIL \u{1b}[39m\u{1b}[22m\u{1b}[27m\u{1b}[0m \u{1b}[2msrc/\u{1b}[22m\u{1b}[1mtest2.test.ts\u{1b}[22m (\u{1b}[0m\u{1b}[1m\u{1b}[41m61.458 s\u{1b}[49m\u{1b}[22m\u{1b}[0m)".to_string(), 478 | " test2".to_string(), 479 | " \u{1b}[32m✓\u{1b}[39m \u{1b}[2msucceeds (1 ms)\u{1b}[22m".to_string(), 480 | " \u{1b}[31m✕\u{1b}[39m \u{1b}[2mfails (2 ms)\u{1b}[22m".to_string(), 481 | " \u{1b}[32m✓\u{1b}[39m \u{1b}[2mfoo (60001 ms)\u{1b}[22m".to_string(), 482 | "".to_string(), 483 | "\u{1b}[1m\u{1b}[31m \u{1b}[1m● \u{1b}[22m\u{1b}[1mtest2 › fails\u{1b}[39m\u{1b}[22m".to_string(), 484 | "".to_string(), 485 | " \u{1b}[2mexpect(\u{1b}[22m\u{1b}[31mreceived\u{1b}[39m\u{1b}[2m).\u{1b}[22mtoBe\u{1b}[2m(\u{1b}[22m\u{1b}[32mexpected\u{1b}[39m\u{1b}[2m) // Object.is equality\u{1b}[22m".to_string(), 486 | "".to_string(), 487 | " Expected: \u{1b}[32mfalse\u{1b}[39m".to_string(), 488 | " Received: \u{1b}[31mtrue\u{1b}[39m".to_string(), 489 | "\u{1b}[2m\u{1b}[22m".to_string(), 490 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 5 |\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 491 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 6 |\u{1b}[39m it(\u{1b}[32m\"fails\"\u{1b}[39m\u{1b}[33m,\u{1b}[39m () \u{1b}[33m=>\u{1b}[39m {\u{1b}[0m\u{1b}[22m".to_string(), 492 | "\u{1b}[2m \u{1b}[0m\u{1b}[31m\u{1b}[1m>\u{1b}[22m\u{1b}[2m\u{1b}[39m\u{1b}[90m 7 |\u{1b}[39m expect(\u{1b}[36mtrue\u{1b}[39m)\u{1b}[33m.\u{1b}[39mtoBe(\u{1b}[36mfalse\u{1b}[39m)\u{1b}[33m;\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 493 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m |\u{1b}[39m \u{1b}[31m\u{1b}[1m^\u{1b}[22m\u{1b}[2m\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 494 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 8 |\u{1b}[39m })\u{1b}[33m;\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 495 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 9 |\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 496 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 10 |\u{1b}[39m it(\u{1b}[32m\"foo\"\u{1b}[39m\u{1b}[33m,\u{1b}[39m \u{1b}[36masync\u{1b}[39m () \u{1b}[33m=>\u{1b}[39m {\u{1b}[0m\u{1b}[22m".to_string(), 497 | "\u{1b}[2m\u{1b}[22m".to_string(), 498 | "\u{1b}[2m \u{1b}[2mat Object. (\u{1b}[22m\u{1b}[2m\u{1b}[0m\u{1b}[36msrc/test2.test.ts\u{1b}[39m\u{1b}[0m\u{1b}[2m:7:18)\u{1b}[22m\u{1b}[2m\u{1b}[22m".to_string(), 499 | ] 500 | },] 501 | ); 502 | } 503 | 504 | #[test] 505 | fn test_more_colors() { 506 | let logs = r#" 507 | 2024-05-29T08:34:09.8655201Z FAIL src/a.spec.tsx (14728 ms) 508 | 2024-05-29T08:34:09.8656607Z utilityFunction 509 | 2024-05-29T08:34:09.8658244Z ✕ should perform action correctly (29 ms) 510 | 2024-05-29T08:34:11.2518625Z ##[group]PASS src/FeatureSection.spec.tsx (44752 ms) 511 | 2024-05-29T08:37:56.8027075Z Summary of all failing tests 512 | 2024-05-29T08:37:56.8042690Z  FAIL  packages/foo/src/a.spec.tsx (14.728 s) 513 | 2024-05-29T08:37:56.8045558Z  ● utilityFunction › should perform action correctly 514 | 2024-05-29T08:37:56.8046501Z 515 | 2024-05-29T08:37:56.8046955Z TypeError: Cannot read properties of undefined (reading 'property') 516 | 2024-05-29T08:37:56.8047659Z  517 | 2024-05-29T08:37:56.8048616Z    228 | // To handle undefined properties safely 518 | 2024-05-29T08:37:56.8049807Z   229 | isEnabled: 519 | 2024-05-29T08:37:56.8051465Z  > 230 | object.property.mode === 'active', 520 | 2024-05-29T08:37:56.8052724Z   | ^ 521 | 2024-05-29T08:37:56.8053954Z   231 | ...(isEnabled ? { isEnabled } : {}), 522 | 2024-05-29T08:37:56.8055365Z   232 | }; 523 | 2024-05-29T08:37:56.8056071Z   233 | }), 524 | 2024-05-29T08:37:56.8056615Z  525 | 2024-05-29T08:37:56.8062690Z  at property (src/fileA.ts:230:38) 526 | 2024-05-29T08:37:56.8063920Z  at Array.map () 527 | 2024-05-29T08:37:56.8065216Z  at map (src/fileA.ts:200:45) 528 | 2024-05-29T08:37:56.8067038Z  at Array.reduce () 529 | 2024-05-29T08:37:56.8068351Z  at reduce (src/fileA.ts:196:61) 530 | 2024-05-29T08:37:56.8118331Z 531 | 2024-05-29T08:37:56.8118337Z 532 | 2024-05-29T08:37:56.8125241Z Test Suites: 1 failed, 1 skipped, 100 passed, 100 of 100 total 533 | 2024-05-29T08:37:56.8127233Z Tests: 1 failed, 21 skipped, 2 todo, 100 passed, 100 total 534 | "#; 535 | let failing_tests = JestLogParser::parse(logs).unwrap(); 536 | assert_eq!( 537 | failing_tests, 538 | vec![ 539 | CheckError { 540 | path: "src/a.spec.tsx".to_string(), 541 | lines: vec![ 542 | "\u{1b}[1m\u{1b}[31m\u{1b}[7mFAIL\u{1b}[27m\u{1b}[39m\u{1b}[22m src/a.spec.tsx (\u{1b}[31m\u{1b}[7m14728 ms\u{1b}[27m\u{1b}[39m)".to_string(), 543 | " utilityFunction".to_string(), 544 | " \u{1b}[31m✕\u{1b}[39m should perform action correctly (29 ms)".to_string(), 545 | ], 546 | }, 547 | CheckError { 548 | path: "packages/foo/src/a.spec.tsx".to_string(), 549 | lines: vec![ 550 | "\u{1b}[0m\u{1b}[7m\u{1b}[1m\u{1b}[31m FAIL \u{1b}[39m\u{1b}[22m\u{1b}[27m\u{1b}[0m \u{1b}[2mpackages/foo/src/\u{1b}[22m\u{1b}[1ma.spec.tsx\u{1b}[22m (\u{1b}[0m\u{1b}[1m\u{1b}[41m14.728 s\u{1b}[49m\u{1b}[22m\u{1b}[0m)".to_string(), 551 | "\u{1b}[1m\u{1b}[31m \u{1b}[1m● \u{1b}[22m\u{1b}[1mutilityFunction › should perform action correctly\u{1b}[39m\u{1b}[22m".to_string(), 552 | "".to_string(), 553 | " TypeError: Cannot read properties of undefined (reading 'property')".to_string(), 554 | "\u{1b}[2m\u{1b}[22m".to_string(), 555 | "\u{1b}[2m \u{1b}[0m \u{1b}[90m 228 |\u{1b}[39m \u{1b}[90m// To handle undefined properties safely\u{1b}[39m\u{1b}[22m".to_string(), 556 | "\u{1b}[2m \u{1b}[90m 229 |\u{1b}[39m isEnabled\u{1b}[33m:\u{1b}[39m\u{1b}[22m".to_string(), 557 | "\u{1b}[2m \u{1b}[31m\u{1b}[1m>\u{1b}[22m\u{1b}[2m\u{1b}[39m\u{1b}[90m 230 |\u{1b}[39m object\u{1b}[33m.\u{1b}[39mproperty\u{1b}[33m.\u{1b}[39mmode \u{1b}[33m===\u{1b}[39m \u{1b}[32m'active'\u{1b}[39m\u{1b}[33m,\u{1b}[39m\u{1b}[22m".to_string(), 558 | "\u{1b}[2m \u{1b}[90m |\u{1b}[39m \u{1b}[31m\u{1b}[1m^\u{1b}[22m\u{1b}[2m\u{1b}[39m\u{1b}[22m".to_string(), 559 | "\u{1b}[2m \u{1b}[90m 231 |\u{1b}[39m \u{1b}[33m...\u{1b}[39m(isEnabled \u{1b}[33m?\u{1b}[39m { isEnabled } \u{1b}[33m:\u{1b}[39m {})\u{1b}[33m,\u{1b}[39m\u{1b}[22m".to_string(), 560 | "\u{1b}[2m \u{1b}[90m 232 |\u{1b}[39m }\u{1b}[33m;\u{1b}[39m\u{1b}[22m".to_string(), 561 | "\u{1b}[2m \u{1b}[90m 233 |\u{1b}[39m })\u{1b}[33m,\u{1b}[39m\u{1b}[0m\u{1b}[22m".to_string(), 562 | "\u{1b}[2m\u{1b}[22m".to_string(), 563 | "\u{1b}[2m \u{1b}[2mat property (\u{1b}[22m\u{1b}[2msrc/fileA.ts\u{1b}[2m:230:38)\u{1b}[22m\u{1b}[2m\u{1b}[22m".to_string(), 564 | "\u{1b}[2m at Array.map ()\u{1b}[22m".to_string(), 565 | "\u{1b}[2m \u{1b}[2mat map (\u{1b}[22m\u{1b}[2msrc/fileA.ts\u{1b}[2m:200:45)\u{1b}[22m\u{1b}[2m\u{1b}[22m".to_string(), 566 | "\u{1b}[2m at Array.reduce ()\u{1b}[22m".to_string(), 567 | "\u{1b}[2m \u{1b}[2mat reduce (\u{1b}[22m\u{1b}[2msrc/fileA.ts\u{1b}[2m:196:61)\u{1b}[22m\u{1b}[2m\u{1b}[22m".to_string(), 568 | ], 569 | }, 570 | ] 571 | ); 572 | } 573 | 574 | #[test] 575 | fn test_find_next_non_ansi_char() { 576 | let str = " \u{1b}[32m\u{1b}[31m "; 577 | let start_col = 1; 578 | assert_eq!(find_next_non_ansi_char(str, start_col), Some(' ')); 579 | } 580 | } 581 | -------------------------------------------------------------------------------- /ghtool/src/commands/test/mod.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use regex::Regex; 3 | 4 | use crate::repo_config::RepoConfig; 5 | use crate::repo_config::TestConfig; 6 | 7 | pub mod jest; 8 | 9 | use jest::*; 10 | 11 | use super::command::CheckError; 12 | use super::command::Command; 13 | use super::command::ConfigPattern; 14 | 15 | impl ConfigPattern for TestConfig { 16 | fn job_pattern(&self) -> &Regex { 17 | &self.job_pattern 18 | } 19 | } 20 | 21 | #[derive(Clone)] 22 | pub struct TestCommand { 23 | config: TestConfig, 24 | } 25 | 26 | impl TestCommand { 27 | pub fn from_repo_config(repo_config: &RepoConfig) -> Result { 28 | let test_config = repo_config 29 | .test 30 | .clone() 31 | .ok_or_else(|| eyre::eyre!("Error: no test section found in .ghtool.toml"))?; 32 | 33 | Ok(Self { 34 | config: test_config, 35 | }) 36 | } 37 | } 38 | 39 | impl Command for TestCommand { 40 | fn name(&self) -> &'static str { 41 | "test" 42 | } 43 | 44 | fn check_error_plural(&self) -> &'static str { 45 | "test errors" 46 | } 47 | 48 | fn config(&self) -> &dyn ConfigPattern { 49 | &self.config 50 | } 51 | 52 | fn parse_log(&self, log: &str) -> Result> { 53 | JestLogParser::parse(log) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ghtool/src/git.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct Repository { 6 | pub owner: String, 7 | pub name: String, 8 | pub hostname: String, 9 | } 10 | 11 | impl std::fmt::Display for Repository { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | write!(f, "{}/{}/{}", self.hostname, self.owner, self.name) 14 | } 15 | } 16 | 17 | pub struct Git { 18 | pub directory: PathBuf, 19 | } 20 | 21 | const GITHUB_HOSTNAME: &str = "github.com"; 22 | 23 | // Example url: git@github.com:raine/tgreddit.git 24 | fn parse_repository(url: &str) -> Result { 25 | let mut parts = url.trim().split(':'); 26 | let host = parts.next(); 27 | let mut parts = parts.next().unwrap().split('/'); 28 | let owner = parts.next().unwrap().to_string(); 29 | let name = parts 30 | .next() 31 | .unwrap() 32 | .strip_suffix(".git") 33 | .unwrap() 34 | .to_string(); 35 | let hostname = host.unwrap().split('@').nth(1).unwrap().to_string(); 36 | Ok(Repository { 37 | owner, 38 | name, 39 | hostname, 40 | }) 41 | } 42 | 43 | // Example input: raine/tgreddit 44 | pub fn parse_repository_from_github(s: &str) -> Result { 45 | let mut parts = s.trim().split('/'); 46 | let owner = parts.next().unwrap().to_string(); 47 | let name = parts.next().unwrap().to_string(); 48 | let hostname = GITHUB_HOSTNAME.to_string(); 49 | 50 | Ok(Repository { 51 | owner, 52 | name, 53 | hostname, 54 | }) 55 | } 56 | 57 | impl Git { 58 | pub fn new(directory: PathBuf) -> Self { 59 | Self { directory } 60 | } 61 | 62 | pub fn get_branch(&self) -> Result { 63 | let output = std::process::Command::new("git") 64 | .arg("rev-parse") 65 | .arg("--abbrev-ref") 66 | .arg("HEAD") 67 | .current_dir(&self.directory) 68 | .output()?; 69 | let branch = String::from_utf8(output.stdout)?; 70 | Ok(branch.trim().to_string()) 71 | } 72 | 73 | pub fn get_remote(&self) -> Result { 74 | let output = std::process::Command::new("git") 75 | .arg("remote") 76 | .arg("get-url") 77 | .arg("origin") 78 | .current_dir(&self.directory) 79 | .output()?; 80 | let url = String::from_utf8(output.stdout)?; 81 | let repository = parse_repository(&url)?; 82 | Ok(repository) 83 | } 84 | } 85 | 86 | #[cfg(test)] 87 | mod tests { 88 | use super::*; 89 | use pretty_assertions::assert_eq; 90 | 91 | #[test] 92 | fn test_parse_repository() { 93 | let url = "git@github.com:raine/tgreddit.git"; 94 | let repository = parse_repository(url).unwrap(); 95 | assert_eq!(repository.owner, "raine"); 96 | assert_eq!(repository.name, "tgreddit"); 97 | assert_eq!(repository.hostname, "github.com"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /ghtool/src/github/auth_client.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use http::HeaderMap; 3 | use serde::Deserialize; 4 | use tracing::{error, info}; 5 | 6 | pub struct GithubAuthClient { 7 | client: reqwest::Client, 8 | } 9 | 10 | const GITHUB_BASE_URI: &str = "https://github.com"; 11 | const CLIENT_ID: &str = "32a2525cc736ee9b63ae"; 12 | const USER_AGENT: &str = "ghtool"; 13 | const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; 14 | 15 | #[derive(Deserialize, Debug)] 16 | pub struct CodeResponse { 17 | pub device_code: String, 18 | pub user_code: String, 19 | pub verification_uri: String, 20 | pub expires_in: u32, 21 | pub interval: u32, 22 | } 23 | 24 | #[derive(Deserialize, Debug)] 25 | pub struct AccessToken { 26 | pub access_token: String, 27 | pub scope: String, 28 | pub token_type: String, 29 | } 30 | 31 | #[derive(Deserialize, Debug)] 32 | pub struct Error { 33 | pub error: String, 34 | pub error_description: String, 35 | pub error_uri: String, 36 | } 37 | 38 | pub enum AccessTokenResponse { 39 | AuthorizationPending(Error), 40 | AccessToken(AccessToken), 41 | } 42 | 43 | impl GithubAuthClient { 44 | pub fn new() -> Result { 45 | let client = reqwest::Client::builder() 46 | .user_agent(USER_AGENT) 47 | .default_headers(make_headers()) 48 | .build() 49 | .map_err(|e| eyre::eyre!("Failed to build client: {}", e))?; 50 | 51 | Ok(Self { client }) 52 | } 53 | 54 | pub async fn get_device_code(&self) -> Result { 55 | let params = [("client_id", CLIENT_ID), ("scope", "repo")]; 56 | let url = format!("{}/login/device/code", GITHUB_BASE_URI); 57 | info!("Requesting device code from {}", url); 58 | let res = self.client.post(url).form(¶ms).send().await?; 59 | let code_response: CodeResponse = res.json().await?; 60 | info!("Received device code: {:?}", code_response); 61 | Ok(code_response) 62 | } 63 | 64 | pub async fn get_access_token(&self, device_code: &str) -> Result { 65 | let params = [ 66 | ("client_id", CLIENT_ID), 67 | ("device_code", device_code), 68 | ("grant_type", GRANT_TYPE), 69 | ]; 70 | let url = format!("{}/login/oauth/access_token", GITHUB_BASE_URI); 71 | info!("Requesting access token from {}", url); 72 | let res = self.client.post(url).form(¶ms).send().await?; 73 | 74 | if res.status().is_success() { 75 | let bytes = res.bytes().await?; 76 | let token_response: Result = serde_json::from_slice(&bytes); 77 | info!("Received response: {:?}", token_response); 78 | match token_response { 79 | Ok(token) => Ok(AccessTokenResponse::AccessToken(token)), 80 | Err(_) => { 81 | let error_response: Error = serde_json::from_slice(&bytes)?; 82 | if error_response.error == "authorization_pending" { 83 | info!(?error_response, "Authorization pending"); 84 | Ok(AccessTokenResponse::AuthorizationPending(error_response)) 85 | } else { 86 | error!(?error_response, "Unexpected error"); 87 | Err(eyre::eyre!( 88 | "Unexpected error: {} - {}", 89 | error_response.error, 90 | error_response.error_description 91 | )) 92 | } 93 | } 94 | } 95 | } else { 96 | Err(eyre::eyre!("Failed to get access token")) 97 | } 98 | } 99 | } 100 | 101 | fn make_headers() -> HeaderMap { 102 | let mut headers = reqwest::header::HeaderMap::new(); 103 | headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap()); 104 | headers 105 | } 106 | -------------------------------------------------------------------------------- /ghtool/src/github/client.rs: -------------------------------------------------------------------------------- 1 | // Adding new graphql queries: 2 | // 3 | // 1. Open https://generator.cynic-rs.dev/ with github schema copied. 4 | // 2. Paste schema. 5 | // 3. Insert graphql query to query builder. 6 | // 4. On the right, copy the generated Rust and create a new file with it. 7 | 8 | use std::borrow::Cow; 9 | use std::time::Duration; 10 | 11 | use cynic::http::CynicReqwestError; 12 | use cynic::QueryBuilder; 13 | use eyre::Result; 14 | use futures::{Future, StreamExt}; 15 | use indicatif::{ProgressBar, ProgressStyle}; 16 | use reqwest::header::HeaderMap; 17 | use tracing::info; 18 | 19 | use crate::github::current_user::CurrentUser; 20 | use crate::spinner::make_spinner_style; 21 | use crate::{ 22 | cache, 23 | github::{ 24 | pull_request_for_branch::{ 25 | extract_pull_request, PullRequestForBranch, PullRequestForBranchVariables, 26 | }, 27 | pull_request_status_checks::{ 28 | extract_check_runs, Node, PullRequestStatusChecks, PullRequestStatusChecksVariables, 29 | }, 30 | }, 31 | }; 32 | 33 | use super::{types::SimpleCheckRun, SimplePullRequest}; 34 | 35 | #[derive(thiserror::Error, Debug)] 36 | pub enum GithubApiError { 37 | /// An error from reqwest when making an HTTP request. 38 | #[error("Error making HTTP request: {0}")] 39 | ReqwestError(#[from] reqwest::Error), 40 | 41 | /// An error response from the server with the given status code and body. 42 | #[error("Server returned {0}: {1}")] 43 | ErrorResponse(reqwest::StatusCode, String), 44 | 45 | // No data in response 46 | #[error("No data in response")] 47 | NoDataInResponse, 48 | } 49 | 50 | pub struct GithubClient { 51 | client: reqwest::Client, 52 | } 53 | 54 | const GITHUB_BASE_URI: &str = "https://api.github.com"; 55 | 56 | impl GithubClient { 57 | pub fn new(oauth_token: &str) -> Result { 58 | let client = Self::make_base_client(oauth_token)?; 59 | Ok(Self { client }) 60 | } 61 | 62 | fn make_headers(oauth_token: &str) -> HeaderMap { 63 | let mut headers = HeaderMap::new(); 64 | headers.insert( 65 | reqwest::header::AUTHORIZATION, 66 | format!("token {}", oauth_token).parse().unwrap(), 67 | ); 68 | headers.insert( 69 | reqwest::header::ACCEPT, 70 | "application/vnd.github.v3+json".parse().unwrap(), 71 | ); 72 | headers 73 | } 74 | 75 | fn make_base_client(oauth_token: &str) -> Result { 76 | reqwest::Client::builder() 77 | .user_agent("ghtool") 78 | .default_headers(Self::make_headers(oauth_token)) 79 | .build() 80 | .map_err(|e| eyre::eyre!("Failed to build client: {}", e)) 81 | } 82 | 83 | async fn run_with_spinner( 84 | &self, 85 | message: Cow<'static, str>, 86 | future: F, 87 | ) -> Result 88 | where 89 | F: Future>, 90 | { 91 | let pb = ProgressBar::new_spinner(); 92 | pb.enable_steady_tick(Duration::from_millis(100)); 93 | pb.set_style(make_spinner_style()); 94 | pb.set_message(message); 95 | let result = future.await; 96 | pb.finish_and_clear(); 97 | 98 | result 99 | } 100 | 101 | pub async fn run_graphql_query( 102 | &self, 103 | operation: cynic::Operation, 104 | ) -> Result 105 | where 106 | T: serde::de::DeserializeOwned + 'static, 107 | K: serde::Serialize, 108 | { 109 | use cynic::http::ReqwestExt; 110 | let graphql_endpoint = format!("{}/graphql", GITHUB_BASE_URI); 111 | 112 | self.client 113 | .post(graphql_endpoint) 114 | .run_graphql(operation) 115 | .await 116 | .map_err(|e| match e { 117 | CynicReqwestError::ReqwestError(err) => GithubApiError::ReqwestError(err), 118 | CynicReqwestError::ErrorResponse(status, body) => { 119 | GithubApiError::ErrorResponse(status, body) 120 | } 121 | }) 122 | .and_then(|response| response.data.ok_or(GithubApiError::NoDataInResponse)) 123 | } 124 | 125 | pub async fn get_pr_for_branch( 126 | &self, 127 | owner: &str, 128 | repo: &str, 129 | branch: &str, 130 | ) -> Result> { 131 | info!(?owner, ?repo, ?branch, "Getting pr for branch"); 132 | let query = PullRequestForBranch::build(PullRequestForBranchVariables { 133 | head_ref_name: branch, 134 | owner, 135 | repo, 136 | states: None, 137 | }); 138 | 139 | let pr_for_branch = self 140 | .run_with_spinner( 141 | "Fetching pull request...".into(), 142 | self.run_graphql_query(query), 143 | ) 144 | .await?; 145 | 146 | info!(?pr_for_branch, "Got pr"); 147 | let pr = extract_pull_request(pr_for_branch); 148 | Ok(pr) 149 | } 150 | 151 | pub async fn get_pr_for_branch_memoized( 152 | &self, 153 | owner: &str, 154 | repo: &str, 155 | branch: &str, 156 | ) -> Result> { 157 | let key = format!("pr_for_branch_{}_{}", repo, branch); 158 | cache::memoize(key, || self.get_pr_for_branch(owner, repo, branch)).await 159 | } 160 | 161 | pub async fn get_pr_status_checks( 162 | &self, 163 | id: &cynic::Id, 164 | with_spinner: bool, 165 | ) -> Result> { 166 | info!(?id, "Getting checks for pr"); 167 | let query = PullRequestStatusChecks::build(PullRequestStatusChecksVariables { id }); 168 | 169 | let pr_checks = if with_spinner { 170 | self.run_with_spinner("Fetching checks...".into(), self.run_graphql_query(query)) 171 | .await? 172 | } else { 173 | self.run_graphql_query(query).await? 174 | }; 175 | 176 | match pr_checks.node { 177 | Some(Node::PullRequest(pull_request)) => { 178 | let check_runs = extract_check_runs(pull_request)?; 179 | Ok(check_runs.into_iter().map(SimpleCheckRun::from).collect()) // convert check runs 180 | } 181 | Some(Node::Unknown) => eyre::bail!("Unknown node type"), 182 | None => eyre::bail!("No node in response"), 183 | } 184 | } 185 | 186 | pub async fn get_job_logs( 187 | &self, 188 | owner: &str, 189 | repo: &str, 190 | job_id: u64, 191 | progress_bar: &ProgressBar, 192 | ) -> Result { 193 | info!(?owner, ?repo, ?job_id, "Getting job logs"); 194 | 195 | let mut got_first_chunk = false; 196 | let url = format!("{GITHUB_BASE_URI}/repos/{owner}/{repo}/actions/jobs/{job_id}/logs",); 197 | let response = self.client.get(url).send().await?.error_for_status()?; 198 | let content_length = response.content_length().unwrap_or(0); 199 | progress_bar.set_length(content_length); 200 | let mut result = bytes::BytesMut::with_capacity(content_length as usize); 201 | let mut stream = response.bytes_stream(); 202 | while let Some(chunk) = stream.next().await { 203 | // Start showing bytes in the progress bar only after first chunk is received 204 | if !got_first_chunk { 205 | progress_bar.set_style( 206 | ProgressStyle::default_bar() 207 | .template("{spinner:.yellow} {msg} {bytes:.dim}") 208 | .unwrap(), 209 | ); 210 | } 211 | 212 | got_first_chunk = true; 213 | let chunk = chunk?; 214 | progress_bar.inc(chunk.len() as u64); 215 | result.extend_from_slice(&chunk); 216 | } 217 | progress_bar.finish_and_clear(); 218 | Ok(result.freeze()) 219 | } 220 | 221 | pub async fn get_current_user(&self) -> Result { 222 | info!("Getting current user"); 223 | let query = CurrentUser::build(()); 224 | let current_user = self 225 | .run_with_spinner( 226 | "Checking login status...".into(), 227 | self.run_graphql_query(query), 228 | ) 229 | .await?; 230 | 231 | info!(?current_user, "Got current user"); 232 | Ok(current_user) 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /ghtool/src/github/current_user.graphql: -------------------------------------------------------------------------------- 1 | query CurrentUser { 2 | viewer { 3 | login 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ghtool/src/github/current_user.rs: -------------------------------------------------------------------------------- 1 | use cynic_github_schema as schema; 2 | 3 | // Below is generated with https://generator.cynic-rs.dev using ./current_user.graphql, 4 | #[derive(cynic::QueryFragment, Debug)] 5 | #[cynic(graphql_type = "Query")] 6 | pub struct CurrentUser { 7 | pub viewer: User, 8 | } 9 | 10 | #[derive(cynic::QueryFragment, Debug)] 11 | pub struct User { 12 | pub login: String, 13 | } 14 | -------------------------------------------------------------------------------- /ghtool/src/github/mod.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use eyre::Result; 3 | use futures::future::try_join_all; 4 | use indicatif::{MultiProgress, ProgressBar}; 5 | use std::collections::HashMap; 6 | use std::time::Duration; 7 | 8 | pub use self::auth_client::{AccessToken, AccessTokenResponse, CodeResponse, GithubAuthClient}; 9 | pub use self::client::{GithubApiError, GithubClient}; 10 | use crate::{git::Repository, spinner::make_spinner_style}; 11 | 12 | pub use current_user::CurrentUser; 13 | pub use pull_request_status_checks::CheckConclusionState; 14 | pub use types::*; 15 | pub use wait_for_pr_checks::*; 16 | 17 | mod auth_client; 18 | mod client; 19 | mod current_user; 20 | mod pull_request_for_branch; 21 | mod pull_request_status_checks; 22 | mod types; 23 | mod wait_for_pr_checks; 24 | 25 | pub async fn fetch_check_run_logs( 26 | client: &GithubClient, 27 | repo: &Repository, 28 | check_runs: &[SimpleCheckRun], 29 | ) -> Result> { 30 | let m = MultiProgress::new(); 31 | let log_futures: Vec<_> = check_runs 32 | .iter() 33 | .map(|cr| { 34 | let pb = m.add(ProgressBar::new_spinner()); 35 | pb.enable_steady_tick(Duration::from_millis(100)); 36 | pb.set_style(make_spinner_style()); 37 | pb.set_message(format!("Fetching logs for check: {}", cr.name)); 38 | 39 | let check_run_id = cr.id; 40 | async move { 41 | let result = client 42 | .get_job_logs(&repo.owner, &repo.name, check_run_id, &pb) 43 | .await; 44 | pb.finish_and_clear(); 45 | result.map(|bytes| (check_run_id, bytes)) 46 | } 47 | }) 48 | .collect(); 49 | 50 | let results = try_join_all(log_futures).await?; 51 | let log_map: HashMap = results.into_iter().collect(); 52 | Ok(log_map) 53 | } 54 | -------------------------------------------------------------------------------- /ghtool/src/github/pull_request_for_branch.graphql: -------------------------------------------------------------------------------- 1 | query PullRequestForBranch( 2 | $owner: String! 3 | $repo: String! 4 | $headRefName: String! 5 | $states: [PullRequestState!] 6 | ) { 7 | repository(owner: $owner, name: $repo) { 8 | pullRequests( 9 | headRefName: $headRefName 10 | states: $states 11 | first: 30 12 | orderBy: { field: CREATED_AT, direction: DESC } 13 | ) { 14 | nodes { 15 | number 16 | headRefName 17 | id 18 | state 19 | baseRefName 20 | isCrossRepository 21 | headRepositoryOwner { 22 | id 23 | login 24 | ... on User { 25 | name 26 | } 27 | } 28 | } 29 | } 30 | defaultBranchRef { 31 | name 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ghtool/src/github/pull_request_for_branch.rs: -------------------------------------------------------------------------------- 1 | use cynic_github_schema as schema; 2 | use serde::Serialize; 3 | 4 | use super::SimplePullRequest; 5 | 6 | pub fn extract_pull_request(pr_for_branch: PullRequestForBranch) -> Option { 7 | pr_for_branch 8 | .repository 9 | .expect("no repository in response") 10 | .pull_requests 11 | .nodes 12 | .expect("no nodes in response") 13 | .into_iter() 14 | .next() 15 | .flatten() 16 | .map(SimplePullRequest::from) 17 | } 18 | 19 | // Below is generated with https://generator.cynic-rs.dev using ./pull_request_for_branch.graphql, 20 | #[derive(cynic::QueryVariables, Debug)] 21 | pub struct PullRequestForBranchVariables<'a> { 22 | pub head_ref_name: &'a str, 23 | pub owner: &'a str, 24 | pub repo: &'a str, 25 | pub states: Option>, 26 | } 27 | 28 | #[derive(cynic::QueryFragment, Debug, Serialize)] 29 | pub struct User { 30 | pub name: Option, 31 | pub id: cynic::Id, 32 | pub login: String, 33 | } 34 | 35 | #[derive(cynic::QueryFragment, Debug)] 36 | #[cynic(graphql_type = "Query", variables = "PullRequestForBranchVariables")] 37 | pub struct PullRequestForBranch { 38 | #[arguments(owner: $owner, name: $repo)] 39 | pub repository: Option, 40 | } 41 | 42 | #[derive(cynic::QueryFragment, Debug)] 43 | #[cynic(variables = "PullRequestForBranchVariables")] 44 | pub struct Repository { 45 | #[arguments(headRefName: $head_ref_name, states: $states, first: 30, orderBy: { direction: "DESC", field: "CREATED_AT" })] 46 | pub pull_requests: PullRequestConnection, 47 | pub default_branch_ref: Option, 48 | } 49 | 50 | #[derive(cynic::QueryFragment, Debug)] 51 | pub struct Ref { 52 | pub name: String, 53 | } 54 | 55 | #[derive(cynic::QueryFragment, Debug)] 56 | pub struct PullRequestConnection { 57 | pub nodes: Option>>, 58 | } 59 | 60 | #[derive(cynic::QueryFragment, Debug, Serialize)] 61 | pub struct PullRequest { 62 | pub number: i32, 63 | pub head_ref_name: String, 64 | pub id: cynic::Id, 65 | pub state: PullRequestState, 66 | pub base_ref_name: String, 67 | pub is_cross_repository: bool, 68 | pub head_repository_owner: Option, 69 | } 70 | 71 | #[derive(cynic::InlineFragments, Debug, Serialize)] 72 | pub enum RepositoryOwner { 73 | User(User), 74 | #[cynic(fallback)] 75 | Unknown, 76 | } 77 | 78 | #[derive(cynic::Enum, Clone, Copy, Debug)] 79 | pub enum PullRequestState { 80 | Closed, 81 | Merged, 82 | Open, 83 | } 84 | -------------------------------------------------------------------------------- /ghtool/src/github/pull_request_status_checks.graphql: -------------------------------------------------------------------------------- 1 | query PullRequestStatusChecks($id: ID!) { 2 | node(id: $id) { 3 | ... on PullRequest { 4 | statusCheckRollup: commits(last: 1) { 5 | nodes { 6 | commit { 7 | statusCheckRollup { 8 | contexts(first: 100) { 9 | nodes { 10 | __typename 11 | ... on CheckRun { 12 | id 13 | url 14 | externalId 15 | name 16 | status 17 | conclusion 18 | startedAt 19 | completedAt 20 | detailsUrl 21 | isRequired(pullRequestId: $id) 22 | databaseId 23 | } 24 | } 25 | pageInfo { 26 | hasNextPage 27 | endCursor 28 | } 29 | } 30 | id 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ghtool/src/github/pull_request_status_checks.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | 3 | pub fn extract_check_runs(pull_request: PullRequest) -> Result> { 4 | let mut nodes = pull_request.status_check_rollup.nodes.unwrap(); 5 | let pull_request_commit = nodes.remove(0); 6 | 7 | Ok(pull_request_commit 8 | .unwrap() 9 | .commit 10 | .status_check_rollup 11 | .ok_or_else(|| eyre::eyre!("No status check rollup found for pull request"))? 12 | .contexts 13 | .nodes 14 | .unwrap() 15 | .into_iter() 16 | .map(|node| node.unwrap()) 17 | .collect::>() 18 | .into_iter() 19 | .filter_map(|x| match x { 20 | StatusCheckRollupContext::CheckRun(check_run) => Some(check_run), 21 | StatusCheckRollupContext::Unknown => None, 22 | }) 23 | .collect::>()) 24 | } 25 | 26 | use cynic_github_schema as schema; 27 | 28 | // https://github.com/obmarg/cynic/issues/713 29 | #[derive(cynic::Scalar, Debug)] 30 | #[cynic(graphql_type = "Int")] 31 | pub struct BigInt(pub u64); 32 | 33 | // Below is generated with https://generator.cynic-rs.dev using ./pull_request_status_checks.graphql, 34 | // except database_id is changed from Option to Option manually. 35 | // https://github.com/obmarg/cynic/issues/711 36 | #[derive(cynic::QueryVariables, Debug)] 37 | pub struct PullRequestStatusChecksVariables<'a> { 38 | pub id: &'a cynic::Id, 39 | } 40 | 41 | #[derive(cynic::QueryFragment, Debug)] 42 | #[cynic(graphql_type = "Query", variables = "PullRequestStatusChecksVariables")] 43 | pub struct PullRequestStatusChecks { 44 | #[arguments(id: $id)] 45 | pub node: Option, 46 | } 47 | 48 | #[derive(cynic::QueryFragment, Debug)] 49 | #[cynic(variables = "PullRequestStatusChecksVariables")] 50 | pub struct PullRequest { 51 | #[arguments(last: 1)] 52 | #[cynic(rename = "commits")] 53 | pub status_check_rollup: PullRequestCommitConnection, 54 | } 55 | 56 | #[derive(cynic::QueryFragment, Debug)] 57 | #[cynic(variables = "PullRequestStatusChecksVariables")] 58 | pub struct PullRequestCommitConnection { 59 | pub nodes: Option>>, 60 | } 61 | 62 | #[derive(cynic::QueryFragment, Debug)] 63 | #[cynic(variables = "PullRequestStatusChecksVariables")] 64 | pub struct PullRequestCommit { 65 | pub commit: Commit, 66 | } 67 | 68 | #[derive(cynic::QueryFragment, Debug)] 69 | #[cynic(variables = "PullRequestStatusChecksVariables")] 70 | pub struct Commit { 71 | pub status_check_rollup: Option, 72 | } 73 | 74 | #[derive(cynic::QueryFragment, Debug)] 75 | #[cynic(variables = "PullRequestStatusChecksVariables")] 76 | pub struct StatusCheckRollup { 77 | #[arguments(first: 100)] 78 | pub contexts: StatusCheckRollupContextConnection, 79 | pub id: cynic::Id, 80 | } 81 | 82 | #[derive(cynic::QueryFragment, Debug)] 83 | #[cynic(variables = "PullRequestStatusChecksVariables")] 84 | pub struct StatusCheckRollupContextConnection { 85 | pub nodes: Option>>, 86 | pub page_info: PageInfo, 87 | } 88 | 89 | #[derive(cynic::QueryFragment, Debug)] 90 | pub struct PageInfo { 91 | pub has_next_page: bool, 92 | pub end_cursor: Option, 93 | } 94 | 95 | #[derive(cynic::QueryFragment, Debug)] 96 | #[cynic(variables = "PullRequestStatusChecksVariables")] 97 | pub struct CheckRun { 98 | pub id: cynic::Id, 99 | pub url: Uri, 100 | pub external_id: Option, 101 | pub name: String, 102 | pub status: CheckStatusState, 103 | pub conclusion: Option, 104 | pub started_at: Option, 105 | pub completed_at: Option, 106 | pub details_url: Option, 107 | #[arguments(pullRequestId: $id)] 108 | pub is_required: bool, 109 | pub database_id: Option, 110 | pub __typename: String, 111 | } 112 | 113 | #[derive(cynic::InlineFragments, Debug)] 114 | #[cynic(variables = "PullRequestStatusChecksVariables")] 115 | pub enum Node { 116 | PullRequest(PullRequest), 117 | #[cynic(fallback)] 118 | Unknown, 119 | } 120 | 121 | #[derive(cynic::InlineFragments, Debug)] 122 | #[cynic(variables = "PullRequestStatusChecksVariables")] 123 | pub enum StatusCheckRollupContext { 124 | CheckRun(CheckRun), 125 | #[cynic(fallback)] 126 | Unknown, 127 | } 128 | 129 | #[derive(cynic::Enum, Clone, Copy, Debug, PartialEq)] 130 | pub enum CheckConclusionState { 131 | ActionRequired, 132 | Cancelled, 133 | Failure, 134 | Neutral, 135 | Skipped, 136 | Stale, 137 | StartupFailure, 138 | Success, 139 | TimedOut, 140 | } 141 | 142 | #[derive(cynic::Enum, Clone, Copy, Debug)] 143 | pub enum CheckStatusState { 144 | Completed, 145 | InProgress, 146 | Pending, 147 | Queued, 148 | Requested, 149 | Waiting, 150 | } 151 | 152 | #[derive(cynic::Scalar, Debug, Clone)] 153 | pub struct DateTime(pub String); 154 | 155 | #[derive(cynic::Scalar, Debug, Clone)] 156 | #[cynic(graphql_type = "URI")] 157 | pub struct Uri(pub String); 158 | -------------------------------------------------------------------------------- /ghtool/src/github/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::{ 5 | pull_request_for_branch::PullRequest, 6 | pull_request_status_checks::{CheckConclusionState, CheckRun}, 7 | }; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct SimpleCheckRun { 11 | pub id: u64, 12 | pub name: String, 13 | pub conclusion: Option, 14 | pub url: Option, 15 | pub started_at: Option>, 16 | pub completed_at: Option>, 17 | } 18 | 19 | impl SimpleCheckRun { 20 | pub fn elapsed(&self) -> Option { 21 | self.started_at.map(|started_at| { 22 | Utc::now() 23 | .signed_duration_since(started_at) 24 | .to_std() 25 | .unwrap() 26 | }) 27 | } 28 | } 29 | 30 | impl From for SimpleCheckRun { 31 | fn from(check_run: CheckRun) -> Self { 32 | SimpleCheckRun { 33 | name: check_run.name, 34 | id: check_run.database_id.unwrap().0, 35 | conclusion: check_run.conclusion, 36 | url: check_run.details_url.map(|e| e.0), 37 | started_at: check_run.started_at.map(|e| { 38 | DateTime::parse_from_rfc3339(&e.0) 39 | .expect("Failed to parse date") 40 | .with_timezone(&chrono::Utc) 41 | }), 42 | completed_at: check_run.completed_at.map(|e| { 43 | DateTime::parse_from_rfc3339(&e.0) 44 | .expect("Failed to parse date") 45 | .with_timezone(&chrono::Utc) 46 | }), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Debug, Clone, Serialize, Deserialize)] 52 | pub struct SimplePullRequest { 53 | pub id: cynic::Id, 54 | } 55 | 56 | impl From for SimplePullRequest { 57 | fn from(pull_request: PullRequest) -> Self { 58 | SimplePullRequest { 59 | id: pull_request.id, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ghtool/src/github/wait_for_pr_checks.rs: -------------------------------------------------------------------------------- 1 | use cynic::Id; 2 | use eyre::Result; 3 | use indicatif::{MultiProgress, ProgressBar}; 4 | use std::collections::HashMap; 5 | use std::sync::{Arc, Mutex}; 6 | use std::time::Duration; 7 | 8 | use crate::spinner::{make_job_completed_spinner, make_job_failed_spinner, make_job_spinner}; 9 | use crate::term::{bold, exit_with_error}; 10 | 11 | use super::{CheckConclusionState, GithubClient, SimpleCheckRun}; 12 | 13 | const POLL_INTERVAL: Duration = Duration::from_secs(10); 14 | 15 | type CheckRunMatcher = dyn Fn(&str) -> bool; 16 | 17 | pub async fn wait_for_pr_checks( 18 | client: &GithubClient, 19 | pull_request_id: Id, 20 | match_checkrun_name: Option<&CheckRunMatcher>, 21 | ) -> Result> { 22 | let m = MultiProgress::new(); 23 | let spinners = Arc::new(Mutex::new(HashMap::new())); 24 | 25 | let mut initial_check_runs = client.get_pr_status_checks(&pull_request_id, true).await?; 26 | if let Some(match_checkrun_name) = match_checkrun_name { 27 | initial_check_runs.retain(|check_run| match_checkrun_name(&check_run.name)); 28 | } 29 | 30 | let any_failed = initial_check_runs.iter().any(|check_run| { 31 | check_run.conclusion.map_or(false, |conclusion| { 32 | conclusion == CheckConclusionState::Failure 33 | }) 34 | }); 35 | 36 | let all_completed = initial_check_runs 37 | .iter() 38 | .all(|check_run| check_run.completed_at.map_or(false, |_| true)); 39 | 40 | if any_failed || all_completed { 41 | return Ok(initial_check_runs); 42 | } 43 | 44 | let max_check_name_length = initial_check_runs 45 | .iter() 46 | .map(|check_run| check_run.name.len()) 47 | .max() 48 | .unwrap_or(0); 49 | 50 | for check_run in initial_check_runs.iter() { 51 | get_or_insert_spinner(&spinners, check_run, &m, max_check_name_length).await; 52 | } 53 | 54 | tokio::time::sleep(POLL_INTERVAL).await; 55 | 56 | let check_runs = loop { 57 | match client.get_pr_status_checks(&pull_request_id, false).await { 58 | Ok(mut check_runs) => { 59 | if let Some(match_checkrun_name) = match_checkrun_name { 60 | check_runs.retain(|check_run| match_checkrun_name(&check_run.name)); 61 | } 62 | 63 | if process_check_runs(&m, &check_runs, &spinners).await { 64 | break check_runs; 65 | } 66 | } 67 | Err(e) => exit_with_error(e), 68 | } 69 | tokio::time::sleep(POLL_INTERVAL).await; 70 | }; 71 | 72 | Ok(check_runs) 73 | } 74 | 75 | async fn process_check_runs( 76 | m: &MultiProgress, 77 | check_runs: &[SimpleCheckRun], 78 | spinners: &Arc>>, 79 | ) -> bool { 80 | let mut any_failed = false; 81 | let mut all_completed = true; 82 | let max_check_name_length = check_runs 83 | .iter() 84 | .map(|check_run| check_run.name.len()) 85 | .max() 86 | .unwrap_or(0); 87 | 88 | for check_run in check_runs.iter() { 89 | let pb = get_or_insert_spinner(spinners, check_run, m, max_check_name_length).await; 90 | if check_run.completed_at.is_some() { 91 | update_spinner_on_completion(&pb, check_run); 92 | } else { 93 | all_completed = false; 94 | } 95 | 96 | any_failed = check_run.conclusion == Some(CheckConclusionState::Failure); 97 | } 98 | 99 | any_failed || all_completed 100 | } 101 | 102 | async fn get_or_insert_spinner( 103 | spinners: &Arc>>, 104 | check_run: &SimpleCheckRun, 105 | m: &MultiProgress, 106 | max_check_name_length: usize, 107 | ) -> ProgressBar { 108 | let mut spinners = spinners.lock().expect("Failed to lock spinners"); 109 | spinners 110 | .entry(check_run.id) 111 | .or_insert_with(|| add_spinner(check_run, m, max_check_name_length)) 112 | .clone() 113 | } 114 | 115 | fn add_spinner( 116 | check_run: &SimpleCheckRun, 117 | m: &MultiProgress, 118 | max_check_name_length: usize, 119 | ) -> ProgressBar { 120 | let mut pb = ProgressBar::new_spinner(); 121 | 122 | if let Some(elapsed) = check_run.elapsed() { 123 | pb = pb.with_elapsed(elapsed); 124 | } 125 | 126 | // Pad the name with max_check_name_length so that elapsed durations are aligned 127 | let padded_name = format!( 128 | "{: ( 142 | make_job_completed_spinner(), 143 | "✓", 144 | format!("Check {} completed in", bold(&check_run.name)), 145 | ), 146 | Some(CheckConclusionState::Failure) => ( 147 | make_job_failed_spinner(), 148 | "X", 149 | format!("Check {} failed in", bold(&check_run.name)), 150 | ), 151 | _ => ( 152 | make_job_spinner(), 153 | "-", 154 | format!("Check {} completed in", bold(&check_run.name)), 155 | ), 156 | }; 157 | 158 | pb.set_style(style); 159 | pb.set_prefix(prefix); 160 | pb.finish_with_message(message); 161 | } 162 | -------------------------------------------------------------------------------- /ghtool/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod cli; 3 | pub mod commands; 4 | pub mod git; 5 | pub mod github; 6 | pub mod repo_config; 7 | pub mod setup; 8 | pub mod spinner; 9 | pub mod term; 10 | pub mod token_store; 11 | -------------------------------------------------------------------------------- /ghtool/src/repo_config.rs: -------------------------------------------------------------------------------- 1 | use eyre::{Result, WrapErr}; 2 | use serde::{Deserialize, Deserializer}; 3 | use std::{fs, path::Path}; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub struct RepoConfig { 7 | pub test: Option, 8 | pub lint: Option, 9 | pub build: Option, 10 | } 11 | 12 | #[derive(Debug, Deserialize, Clone)] 13 | pub struct TestConfig { 14 | #[serde(deserialize_with = "deserialize_regex")] 15 | pub job_pattern: regex::Regex, 16 | pub tool: TestRunner, 17 | } 18 | 19 | #[derive(Debug, Deserialize, Clone)] 20 | pub struct LintConfig { 21 | #[serde(deserialize_with = "deserialize_regex")] 22 | pub job_pattern: regex::Regex, 23 | pub tool: LintTool, 24 | } 25 | 26 | #[derive(Debug, Deserialize, Clone)] 27 | pub struct BuildConfig { 28 | #[serde(deserialize_with = "deserialize_regex")] 29 | pub job_pattern: regex::Regex, 30 | pub tool: BuildTool, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub enum TestRunner { 35 | Jest, 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | pub enum LintTool { 40 | Eslint, 41 | } 42 | 43 | #[derive(Debug, Clone)] 44 | pub enum BuildTool { 45 | Tsc, 46 | } 47 | 48 | fn deserialize_tool<'de, D, T>( 49 | deserializer: D, 50 | valid_tool: &'static str, 51 | tool: T, 52 | tool_name: &str, 53 | ) -> Result 54 | where 55 | D: Deserializer<'de>, 56 | { 57 | let s = String::deserialize(deserializer)?; 58 | if s.eq_ignore_ascii_case(valid_tool) { 59 | Ok(tool) 60 | } else { 61 | Err(serde::de::Error::custom(format!( 62 | "invalid {}: {}", 63 | tool_name, s 64 | ))) 65 | } 66 | } 67 | 68 | impl<'de> Deserialize<'de> for TestRunner { 69 | fn deserialize(deserializer: D) -> Result 70 | where 71 | D: Deserializer<'de>, 72 | { 73 | deserialize_tool(deserializer, "jest", TestRunner::Jest, "test runner") 74 | } 75 | } 76 | 77 | impl<'de> Deserialize<'de> for LintTool { 78 | fn deserialize(deserializer: D) -> Result 79 | where 80 | D: Deserializer<'de>, 81 | { 82 | deserialize_tool(deserializer, "eslint", LintTool::Eslint, "lint tool") 83 | } 84 | } 85 | 86 | impl<'de> Deserialize<'de> for BuildTool { 87 | fn deserialize(deserializer: D) -> Result 88 | where 89 | D: Deserializer<'de>, 90 | { 91 | deserialize_tool(deserializer, "tsc", BuildTool::Tsc, "build tool") 92 | } 93 | } 94 | 95 | fn deserialize_regex<'de, D>(deserializer: D) -> Result 96 | where 97 | D: Deserializer<'de>, 98 | { 99 | let s = String::deserialize(deserializer)?; 100 | regex::Regex::new(&s).map_err(serde::de::Error::custom) 101 | } 102 | 103 | pub fn read_repo_config_from_path(config_path: &Path) -> Result { 104 | let config_str = fs::read_to_string(config_path).wrap_err_with(|| { 105 | format!( 106 | "Error reading config from path {}", 107 | config_path.to_string_lossy() 108 | ) 109 | })?; 110 | let config: RepoConfig = toml::from_str(&config_str)?; 111 | Ok(config) 112 | } 113 | 114 | pub fn read_repo_config(repo_path: &Path) -> Result { 115 | let config_path = repo_path.join(".ghtool.toml"); 116 | read_repo_config_from_path(&config_path) 117 | } 118 | -------------------------------------------------------------------------------- /ghtool/src/setup.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | path::{Path, PathBuf}, 4 | sync::Arc, 5 | thread, 6 | }; 7 | 8 | use clap::Parser; 9 | use eyre::{Context, Result}; 10 | use tracing::info; 11 | use tracing_subscriber::EnvFilter; 12 | 13 | use crate::{ 14 | cli::Cli, 15 | git::{parse_repository_from_github, Git, Repository}, 16 | repo_config::{read_repo_config, read_repo_config_from_path, RepoConfig}, 17 | }; 18 | 19 | pub fn setup() -> Result { 20 | let cli = Cli::parse(); 21 | 22 | if cli.verbose { 23 | std::env::set_var("RUST_LOG", "info"); 24 | } 25 | 26 | setup_env()?; 27 | Ok(cli) 28 | } 29 | 30 | fn setup_env() -> Result<()> { 31 | color_eyre::install()?; 32 | 33 | if std::env::var("RUST_LIB_BACKTRACE").is_err() { 34 | std::env::set_var("RUST_LIB_BACKTRACE", "1"); 35 | } 36 | 37 | tracing_subscriber::fmt() 38 | .without_time() 39 | .with_env_filter(EnvFilter::from_default_env()) 40 | .init(); 41 | 42 | Ok(()) 43 | } 44 | 45 | pub fn get_repo_config(cli: &Cli) -> Result<(RepoConfig, Repository, String)> { 46 | let env_repo_config = env::var("REPO_CONFIG") 47 | .map(|p| Path::new(&p).to_path_buf()) 48 | .map_err(|e| eyre::eyre!("Error getting repo config path: {}", e)) 49 | .and_then(|p| read_repo_config_from_path(&p)); 50 | let repo_from_env = env::var("REPO").map(|s| parse_repository_from_github(&s).unwrap()); 51 | 52 | // The env variables are meant to help with development. I opted to not put them as cli 53 | // arguments as they would make --help more noisy. 54 | let (repo_config, repo, branch) = match (env_repo_config, repo_from_env) { 55 | (Ok(repo_config), Ok(repo)) => { 56 | let branch = cli.branch.clone().ok_or_else(|| { 57 | eyre::eyre!("Error: --branch must be given when using REPO env variable") 58 | })?; 59 | (repo_config, repo, branch) 60 | } 61 | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { 62 | eyre::bail!("Error: both env variables REPO and REPO_CONFIG should be given at the same time or not at all") 63 | } 64 | (Err(_), Err(_)) => { 65 | let repo_path = get_repo_path()?; 66 | let (repo, current_branch) = get_git_info(&repo_path, cli)?; 67 | let repo_config = read_repo_config(&repo_path)?; 68 | (repo_config, repo, current_branch) 69 | } 70 | }; 71 | 72 | info!(?repo_config, ?repo, "config"); 73 | Ok((repo_config, repo, branch)) 74 | } 75 | 76 | fn find_git_ancestor(mut dir: PathBuf) -> Option { 77 | loop { 78 | let git_dir = dir.join(".git"); 79 | if git_dir.is_dir() { 80 | return Some(dir); 81 | } 82 | 83 | if !dir.pop() { 84 | return None; 85 | } 86 | } 87 | } 88 | 89 | fn get_repo_path() -> Result { 90 | env::var("REPO_PATH") 91 | .map(|p| Path::new(&p).to_path_buf()) 92 | .or_else(|_| env::current_dir().wrap_err("Failed to get current directory")) 93 | .and_then(|path| find_git_ancestor(path).ok_or(eyre::eyre!("Not in git repository"))) 94 | .map_err(|e| eyre::eyre!("Error getting repo path: {}", e)) 95 | } 96 | 97 | fn get_git_info(repo_path: &Path, cli: &Cli) -> Result<(Repository, String)> { 98 | let git = Arc::new(Git::new(repo_path.to_path_buf())); 99 | let git1 = Arc::clone(&git); 100 | let handle1 = thread::spawn(move || git1.get_remote()); 101 | let branch = match &cli.branch { 102 | Some(branch) => branch.clone(), 103 | None => { 104 | let git2 = Arc::clone(&git); 105 | let handle2 = thread::spawn(move || git2.get_branch()); 106 | handle2.join().unwrap()? 107 | } 108 | }; 109 | let repo = handle1.join().unwrap()?; 110 | Ok((repo, branch)) 111 | } 112 | -------------------------------------------------------------------------------- /ghtool/src/spinner.rs: -------------------------------------------------------------------------------- 1 | use indicatif::ProgressStyle; 2 | 3 | const TICK_CHARS: &str = "⠁⠂⠄⡀⢀⠠⠐⠈ "; 4 | 5 | pub fn make_spinner_style() -> ProgressStyle { 6 | ProgressStyle::with_template("{spinner:.yellow.bold} {msg}") 7 | .unwrap() 8 | .tick_chars(TICK_CHARS) 9 | } 10 | 11 | pub fn make_job_spinner() -> ProgressStyle { 12 | ProgressStyle::with_template("{spinner:.yellow.bold} {msg} {elapsed:.dim}") 13 | .unwrap() 14 | .tick_chars(TICK_CHARS) 15 | } 16 | 17 | pub fn make_job_completed_spinner() -> ProgressStyle { 18 | ProgressStyle::with_template("{prefix:.green} {msg} {elapsed}") 19 | .unwrap() 20 | .tick_chars(TICK_CHARS) 21 | } 22 | 23 | pub fn make_job_failed_spinner() -> ProgressStyle { 24 | ProgressStyle::with_template("{prefix:.red} {msg} {elapsed}") 25 | .unwrap() 26 | .tick_chars(TICK_CHARS) 27 | } 28 | -------------------------------------------------------------------------------- /ghtool/src/term.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | use eyre::Result; 4 | 5 | use crate::github; 6 | 7 | pub fn bold(text: &str) -> String { 8 | format!("\x1b[1m{}\x1b[0m", text) 9 | } 10 | 11 | pub fn green(text: &str) -> String { 12 | format!("\x1b[32m{}\x1b[0m", text) 13 | } 14 | 15 | pub fn print_header(header: &str) { 16 | if let Some((w, _)) = term_size::dimensions() { 17 | let lines = header.split('\n').collect::>(); 18 | let horizontal_border = "─".repeat(w - 2); 19 | let border = format!("┌{}┐", horizontal_border); 20 | let end_border = format!("└{}┘", horizontal_border); 21 | println!("{}", border); 22 | for line in lines { 23 | let stripped_line = strip_ansi_escapes::strip(line); 24 | let mut line = String::from_utf8(stripped_line).unwrap(); 25 | let line_len = line.chars().count(); 26 | if line_len > w - 4 { 27 | let truncated_line_len = w - 7; // For ellipsis and spaces 28 | line = line.chars().take(truncated_line_len).collect::(); 29 | line.push_str("..."); 30 | } 31 | let line_padding = w - line.chars().count() - 4; 32 | let header_line = format!("│ {}{} │", line, " ".repeat(line_padding)); 33 | println!("{}", header_line); 34 | } 35 | println!("{}", end_border); 36 | } 37 | } 38 | 39 | pub fn exit_with_error(e: eyre::Error) -> T { 40 | eprintln!("{}", e); 41 | std::process::exit(1); 42 | } 43 | 44 | pub fn print_check_run_header(check_run: &github::SimpleCheckRun) { 45 | print_header(&format!( 46 | "{} {}\n{} {}", 47 | bold("Job:"), 48 | check_run.name, 49 | bold("Url:"), 50 | check_run.url.as_ref().unwrap() 51 | )); 52 | } 53 | 54 | pub fn print_all_checks_green() { 55 | eprintln!("{} All checks are green", green("✓")); 56 | } 57 | 58 | pub fn read_stdin() -> Result { 59 | let mut input = String::new(); 60 | io::stdin().read_line(&mut input)?; 61 | Ok(input.trim().to_string()) 62 | } 63 | 64 | pub fn prompt_for_user_to_continue(prompt_message: &str) -> io::Result<()> { 65 | print!("{}", prompt_message); 66 | io::stdout().flush()?; 67 | 68 | let mut input = String::new(); 69 | io::stdin().read_line(&mut input)?; 70 | Ok(()) 71 | } 72 | -------------------------------------------------------------------------------- /ghtool/src/token_store.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use keyring::{error::Error, Entry}; 3 | use tracing::info; 4 | 5 | pub fn set_token(hostname: &str, token: &str) -> Result<(), Error> { 6 | let entry = Entry::new("ghtool", hostname)?; 7 | info!("Setting token for {}", hostname); 8 | entry.set_password(token) 9 | } 10 | 11 | pub fn get_token(hostname: &str) -> Result { 12 | let entry = Entry::new("ghtool", hostname)?; 13 | let token = entry.get_password()?; 14 | info!("Got token for {}: {}", hostname, token); 15 | Ok(token) 16 | } 17 | 18 | pub fn delete_token(hostname: &str) -> Result<(), Error> { 19 | let entry = Entry::new("ghtool", hostname)?; 20 | info!("Deleting token for {}", hostname); 21 | entry.delete_password() 22 | } 23 | -------------------------------------------------------------------------------- /ghtool_devtools/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ghtool_devtools" 3 | version = "0.0.1" 4 | edition = "2021" 5 | repository = "https://github.com/raine/ghtool" 6 | readme = "../README.md" 7 | 8 | [dependencies] 9 | ghtool = { path = "../ghtool" } 10 | eyre = "0.6.8" 11 | -------------------------------------------------------------------------------- /ghtool_devtools/src/bin/parse_jest_log.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use ghtool::commands::jest::JestLogParser; 3 | 4 | fn main() -> Result<()> { 5 | let file_path = std::env::args().nth(1).unwrap(); 6 | let log = std::fs::read_to_string(file_path).unwrap(); 7 | let parsed = JestLogParser::parse(&log)?; 8 | println!("{parsed:#?}"); 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /github_schema/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cynic-github-schema" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Github graphql schema for cynic" 6 | license = "MIT" 7 | 8 | [dependencies] 9 | cynic = { version = "3.7.0" } 10 | -------------------------------------------------------------------------------- /github_schema/src/lib.rs: -------------------------------------------------------------------------------- 1 | cynic::use_schema!("./github.graphql"); 2 | --------------------------------------------------------------------------------