├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── actionlint.yaml │ ├── autofix.yaml │ ├── check-commit-signing.yaml │ ├── release.yaml │ ├── test.yaml │ ├── watch-star.yaml │ ├── wc-renovate-config-validator.yaml │ ├── wc-test.yaml │ └── workflow_call_test.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── USAGE.md ├── aqua ├── aqua-checksums.json ├── aqua.yaml └── imports │ ├── actionlint.yaml │ ├── cmdx.yaml │ ├── cosign.yaml │ ├── ghcp.yaml │ ├── golangci-lint.yaml │ ├── goreleser.yaml │ └── reviewdog.yaml ├── cmd └── ghatm │ └── main.go ├── cmdx.yaml ├── go.mod ├── go.sum ├── pkg ├── cli │ ├── completion.go │ ├── runner.go │ └── set.go ├── controller │ └── set │ │ ├── estimate.go │ │ ├── set.go │ │ └── workflow.go ├── edit │ ├── ast.go │ ├── edit.go │ ├── edit_internal_test.go │ ├── testdata │ │ ├── invalid_jobs.yaml │ │ ├── jobs_not_found.yaml │ │ ├── nochange.yaml │ │ ├── normal.yaml │ │ ├── normal_result.yaml │ │ ├── reusable_workflow_timeout.yaml │ │ ├── reusable_workflow_timeout_result.yaml │ │ └── unmarshal_error.yaml │ └── workflow.go ├── github │ ├── github.go │ ├── workflow_job.go │ └── workflow_run.go └── log │ ├── log.go │ └── log_windows.go ├── renovate.json5 ├── scripts ├── coverage.sh ├── fmt.sh └── generate-usage.sh └── testdata ├── after.yaml └── before.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.json] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 2 | github: 3 | - suzuki-shunsuke 4 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: actionlint 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | actionlint: 7 | runs-on: ubuntu-24.04 8 | if: failure() 9 | timeout-minutes: 10 10 | permissions: {} 11 | needs: 12 | - main 13 | steps: 14 | - run: exit 1 15 | main: 16 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@dbe6151b36d408b24ca5c41a34291b2b6d1bff76 # v2.0.1 17 | permissions: 18 | pull-requests: write 19 | contents: read 20 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: autofix.ci 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | autofix: 7 | runs-on: ubuntu-24.04 8 | permissions: {} 9 | timeout-minutes: 15 10 | steps: 11 | - uses: suzuki-shunsuke/go-autofix-action@0bb6ca06b2f0d2d23c200bbbaa650897824a6cb9 # v0.1.7 12 | with: 13 | aqua_version: v2.51.2 14 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-signing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check if all commits are signed 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target 8 | cancel-in-progress: true 9 | jobs: 10 | check-commit-signing: 11 | uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: [v*] 6 | permissions: {} 7 | jobs: 8 | release: 9 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@4602cd60ba10f19df17a074d76c518a9b8b979bb # v4.0.1 10 | with: 11 | go-version-file: go.mod 12 | aqua_policy_allow: true 13 | aqua_version: v2.51.2 14 | permissions: 15 | contents: write 16 | id-token: write 17 | actions: read 18 | attestations: write 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | permissions: {} 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/workflow_call_test.yaml 11 | permissions: 12 | contents: read 13 | status-check: 14 | runs-on: ubuntu-24.04 15 | if: failure() 16 | timeout-minutes: 10 17 | permissions: {} 18 | needs: 19 | - test 20 | steps: 21 | - run: exit 1 22 | -------------------------------------------------------------------------------- /.github/workflows/watch-star.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: watch-star 3 | on: 4 | watch: 5 | types: 6 | - started 7 | jobs: 8 | watch-star: 9 | timeout-minutes: 30 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | steps: 14 | - uses: suzuki-shunsuke/watch-star-action@2b3d259ce2ea06d53270dfe33a66d5642c8010ca # v0.1.1 15 | with: 16 | number: 1 17 | -------------------------------------------------------------------------------- /.github/workflows/wc-renovate-config-validator.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: renovate-config-validator 3 | on: workflow_call 4 | jobs: 5 | renovate-config-validator: 6 | # Validate Renovate Configuration by renovate-config-validator. 7 | uses: suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@e8effbd185cbe3874cddef63f48b8bdcfc9ada55 # v0.2.4 8 | permissions: 9 | contents: read 10 | -------------------------------------------------------------------------------- /.github/workflows/wc-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: wc-test 3 | on: 4 | workflow_call: 5 | inputs: 6 | docker_is_changed: 7 | required: false 8 | type: boolean 9 | 10 | jobs: 11 | test: 12 | timeout-minutes: 30 13 | runs-on: ubuntu-latest 14 | permissions: {} 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 20 | with: 21 | go-version-file: go.mod 22 | cache: true 23 | - uses: aquaproj/aqua-installer@9ebf656952a20c45a5d66606f083ff34f58b8ce0 # v4.0.0 24 | with: 25 | aqua_version: v2.51.2 26 | - run: golangci-lint run --timeout 120s 27 | env: 28 | AQUA_GITHUB_TOKEN: ${{github.token}} 29 | - run: go test -v ./... -race -covermode=atomic 30 | - run: go run ./cmd/ghatm set testdata/before.yaml 31 | - run: diff testdata/before.yaml testdata/after.yaml 32 | - run: go run ./cmd/ghatm set -auto 33 | env: 34 | GITHUB_TOKEN: ${{github.token}} 35 | - run: git diff --exit-code .github/workflows/test.yaml 36 | -------------------------------------------------------------------------------- /.github/workflows/workflow_call_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test (workflow_call) 3 | on: workflow_call 4 | permissions: {} 5 | jobs: 6 | path-filter: 7 | # Get changed files to filter jobs 8 | outputs: 9 | renovate-config-validator: ${{steps.changes.outputs.renovate-config-validator}} 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | permissions: {} 13 | steps: 14 | - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 15 | id: changes 16 | with: 17 | filters: | 18 | renovate-config-validator: 19 | - renovate.json5 20 | - .github/workflows/test.yaml 21 | - .github/workflows/wc-renovate-config-validator.yaml 22 | 23 | renovate-config-validator: 24 | uses: ./.github/workflows/wc-renovate-config-validator.yaml 25 | needs: path-filter 26 | if: needs.path-filter.outputs.renovate-config-validator == 'true' 27 | permissions: 28 | contents: read 29 | 30 | test: 31 | uses: ./.github/workflows/wc-test.yaml 32 | needs: path-filter 33 | permissions: {} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - err113 7 | - exhaustive 8 | - exhaustruct 9 | - godot 10 | - godox 11 | - gomoddirectives 12 | - ireturn 13 | - lll 14 | - musttag 15 | - nlreturn 16 | - nonamedreturns 17 | - tagalign 18 | - tagliatelle 19 | - varnamelen 20 | - wsl 21 | exclusions: 22 | generated: lax 23 | presets: 24 | - comments 25 | - common-false-positives 26 | - legacy 27 | - std-error-handling 28 | paths: 29 | - third_party$ 30 | - builtin$ 31 | - examples$ 32 | formatters: 33 | enable: 34 | - gci 35 | - gofmt 36 | - gofumpt 37 | - goimports 38 | exclusions: 39 | generated: lax 40 | paths: 41 | - third_party$ 42 | - builtin$ 43 | - examples$ 44 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | project_name: ghatm 4 | 5 | archives: 6 | - name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" 7 | format_overrides: 8 | - goos: windows 9 | formats: [zip] 10 | 11 | builds: 12 | - binary: ghatm 13 | main: cmd/ghatm/main.go 14 | env: 15 | - CGO_ENABLED=0 16 | goos: 17 | - windows 18 | - darwin 19 | - linux 20 | goarch: 21 | - amd64 22 | - arm64 23 | 24 | release: 25 | prerelease: true 26 | header: | 27 | [Pull Requests](https://github.com/suzuki-shunsuke/ghatm/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/ghatm/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/ghatm/compare/{{.PreviousTag}}...{{.Tag}} 28 | 29 | signs: 30 | - cmd: cosign 31 | artifacts: checksum 32 | signature: ${artifact}.sig 33 | certificate: ${artifact}.pem 34 | output: true 35 | args: 36 | - sign-blob 37 | - "-y" 38 | - --output-signature 39 | - ${signature} 40 | - --output-certificate 41 | - ${certificate} 42 | - --oidc-provider 43 | - github 44 | - ${artifact} 45 | 46 | brews: 47 | - 48 | # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the 49 | # same kind. We will probably unify this in the next major version like it is done with scoop. 50 | 51 | # GitHub/GitLab repository to push the formula to 52 | repository: 53 | owner: suzuki-shunsuke 54 | name: homebrew-ghatm 55 | # The project name and current git tag are used in the format string. 56 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 57 | # Your app's homepage. 58 | # Default is empty. 59 | homepage: https://github.com/suzuki-shunsuke/ghatm 60 | 61 | # Template of your app's description. 62 | # Default is empty. 63 | description: | 64 | Set timeout-minutes to GitHub Actions jobs 65 | license: MIT 66 | 67 | # Setting this will prevent goreleaser to actually try to commit the updated 68 | # formula - instead, the formula file will be stored on the dist folder only, 69 | # leaving the responsibility of publishing it to the user. 70 | # If set to auto, the release will not be uploaded to the homebrew tap 71 | # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 72 | # Default is false. 73 | skip_upload: true 74 | 75 | # So you can `brew test` your formula. 76 | # Default is empty. 77 | test: | 78 | system "#{bin}/ghatm --version" 79 | 80 | # Additional install instructions so you don't need to override `install`. 81 | # 82 | # Template: allowed 83 | # Since: v1.20 84 | extra_install: | 85 | generate_completions_from_executable(bin/"ghatm", "completion", shells: [:bash, :zsh, :fish]) 86 | 87 | scoops: 88 | - 89 | description: | 90 | Set timeout-minutes to GitHub Actions jobs 91 | license: MIT 92 | skip_upload: true 93 | repository: 94 | owner: suzuki-shunsuke 95 | name: scoop-bucket 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Shunsuke Suzuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghatm 2 | 3 | `ghatm` is a command line tool setting [timeout-minutes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) to all GitHub Actions jobs. 4 | It finds GitHub Actions workflows and adds `timeout-minutes` to jobs which don't have the setting. 5 | It edits workflow files while keeping YAML comments, indents, empty lines, and so on. 6 | 7 | ```console 8 | $ ghatm set 9 | ``` 10 | 11 | ```diff 12 | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml 13 | index e8c6ae7..aba3b2d 100644 14 | --- a/.github/workflows/test.yaml 15 | +++ b/.github/workflows/test.yaml 16 | @@ -6,6 +6,7 @@ on: pull_request 17 | jobs: 18 | path-filter: 19 | # Get changed files to filter jobs 20 | + timeout-minutes: 30 21 | outputs: 22 | update-aqua-checksums: ${{steps.changes.outputs.update-aqua-checksums}} 23 | renovate-config-validator: ${{steps.changes.outputs.renovate-config-validator}} 24 | @@ -71,6 +72,7 @@ jobs: 25 | contents: read 26 | 27 | build: 28 | + timeout-minutes: 30 29 | runs-on: ubuntu-latest 30 | permissions: {} 31 | steps: 32 | ``` 33 | 34 | ## Motivation 35 | 36 | - https://exercism.org/docs/building/github/gha-best-practices#h-set-timeouts-for-workflows 37 | - [job_timeout_minutes_is_required | suzuki-shunsuke/ghalint](https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/012.md) 38 | - [job_timeout_minutes_is_required | lintnet-modules/ghalint](https://github.com/lintnet-modules/ghalint/tree/main/workflow/job_timeout_minutes_is_required) 39 | 40 | `timeout-minutes` should be set properly, but it's so bothersome to fix a lot of workflow files by hand. 41 | `ghatm` fixes them automatically. 42 | 43 | ## How to install 44 | 45 | `ghatm` is a single binary written in Go. 46 | So you only need to put the executable binary into `$PATH`. 47 | 48 | 1. [Homebrew](https://brew.sh/) 49 | 50 | ```sh 51 | brew install suzuki-shunsuke/ghatm/ghatm 52 | ``` 53 | 54 | 2. [Scoop](https://scoop.sh/) 55 | 56 | ```sh 57 | scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket 58 | scoop install ghatm 59 | ``` 60 | 61 | 3. [aqua](https://aquaproj.github.io/) 62 | 63 | ```sh 64 | aqua g -i suzuki-shunsuke/ghatm 65 | ``` 66 | 67 | 4. Download a prebuilt binary from [GitHub Releases](https://github.com/suzuki-shunsuke/ghatm/releases) and install it into `$PATH` 68 | 69 |
70 | Verify downloaded assets from GitHub Releases 71 | 72 | You can verify downloaded assets using some tools. 73 | 74 | 1. [GitHub CLI](https://cli.github.com/) 75 | 1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier) 76 | 1. [Cosign](https://github.com/sigstore/cosign) 77 | 78 | -- 79 | 80 | 1. GitHub CLI 81 | 82 | ghatm >= v0.3.3 83 | 84 | You can install GitHub CLI by aqua. 85 | 86 | ```sh 87 | aqua g -i cli/cli 88 | ``` 89 | 90 | ```sh 91 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 -p ghatm_darwin_arm64.tar.gz 92 | gh attestation verify ghatm_darwin_arm64.tar.gz \ 93 | -R suzuki-shunsuke/ghatm \ 94 | --signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml 95 | ``` 96 | 97 | Output: 98 | 99 | ``` 100 | Loaded digest sha256:84298e8436f0b2c7f51cd4606848635471a11aaa03d7d0c410727630defe6b7e for file://ghatm_darwin_arm64.tar.gz 101 | Loaded 1 attestation from GitHub API 102 | ✓ Verification succeeded! 103 | 104 | sha256:84298e8436f0b2c7f51cd4606848635471a11aaa03d7d0c410727630defe6b7e was attested by: 105 | REPO PREDICATE_TYPE WORKFLOW 106 | suzuki-shunsuke/go-release-workflow https://slsa.dev/provenance/v1 .github/workflows/release.yaml@7f97a226912ee2978126019b1e95311d7d15c97a 107 | ``` 108 | 109 | 2. slsa-verifier 110 | 111 | You can install slsa-verifier by aqua. 112 | 113 | ```sh 114 | aqua g -i slsa-framework/slsa-verifier 115 | ``` 116 | 117 | ```sh 118 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 -p ghatm_darwin_arm64.tar.gz -p multiple.intoto.jsonl 119 | slsa-verifier verify-artifact ghatm_darwin_arm64.tar.gz \ 120 | --provenance-path multiple.intoto.jsonl \ 121 | --source-uri github.com/suzuki-shunsuke/ghatm \ 122 | --source-tag v0.3.3 123 | ``` 124 | 125 | Output: 126 | 127 | ``` 128 | Verified signature against tlog entry index 137035428 at URL: https://rekor.sigstore.dev/api/v1/log/entries/108e9186e8c5677a421587935f03afc5f73475e880b6f05962c5be8726ccb5011b7bf62a5d2a58bb 129 | Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0" at commit 1af80d4aa0b6cc45bda5677fd45202ee2b90e1fc 130 | Verifying artifact ghatm_darwin_arm64.tar.gz: PASSED 131 | ``` 132 | 133 | 3. Cosign 134 | 135 | You can install Cosign by aqua. 136 | 137 | ```sh 138 | aqua g -i sigstore/cosign 139 | ``` 140 | 141 | ```sh 142 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 143 | cosign verify-blob \ 144 | --signature ghatm_0.3.3_checksums.txt.sig \ 145 | --certificate ghatm_0.3.3_checksums.txt.pem \ 146 | --certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \ 147 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ 148 | ghatm_0.3.3_checksums.txt 149 | ``` 150 | 151 | Output: 152 | 153 | ``` 154 | Verified OK 155 | ``` 156 | 157 | After verifying the checksum, verify the artifact. 158 | 159 | ```sh 160 | cat ghatm_0.3.3_checksums.txt | sha256sum -c --ignore-missing 161 | ``` 162 | 163 |
164 | 165 | 5. Go 166 | 167 | ```sh 168 | go install github.com/suzuki-shunsuke/ghatm/cmd/ghatm@latest 169 | ``` 170 | 171 | ## How to use 172 | 173 | Please run `ghatm set` on the repository root directory. 174 | 175 | ```sh 176 | ghatm set 177 | ``` 178 | 179 | Then `ghatm` checks GitHub Actions workflows `^\.github/workflows/.*\.ya?ml$` and sets `timeout-minutes: 30` to jobs not having `timeout-minutes`. 180 | Jobs with `timeout-minutes` aren't changed. 181 | You can specify the value of `timeout-minutes` with `-t` option. 182 | 183 | ```sh 184 | ghatm set -t 60 185 | ``` 186 | 187 | You can specify workflow files by positional arguments. 188 | 189 | ```sh 190 | ghatm set .github/workflows/test.yaml 191 | ``` 192 | 193 | ### Decide `timeout-minutes` based on each job's past execution times 194 | 195 | ```sh 196 | ghatm set -auto [-repo ] [-size ] 197 | ``` 198 | 199 | ghatm >= v0.3.2 [#68](https://github.com/suzuki-shunsuke/ghatm/issues/68) [#70](https://github.com/suzuki-shunsuke/ghatm/pull/70) 200 | 201 | > [!warning] 202 | > The feature doesn't support workflows using `workflow_call`. 203 | 204 | If the `-auto` option is used, ghatm calls GitHub API to get each job's past execution times and decide appropriate `timeout-minutes`. 205 | This feature requires a GitHub access token with the `actions:read` permission. 206 | You have to set the access token to the environment variable `GITHUB_TOKEN` or `GHATM_GITHUB_TOKEN`. 207 | 208 | GitHub API: 209 | 210 | - [List workflow runs for a workflow](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow) 211 | - [List jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) 212 | 213 | ghatm takes 30 jobs by job to decide `timeout-minutes`. 214 | You can change the number of jobs by the `-size` option. 215 | 216 | ``` 217 | max(job execution times) + 10 218 | ``` 219 | 220 | ## Tips: Fix workflows by CI 221 | 222 | Using `ghatm` in CI, you can fix workflows automatically. 223 | When workflow files are added or changed in a pull request, you can run `ghatm` and commit and push changes to a feature branch. 224 | 225 | ## LICENSE 226 | 227 | [MIT](LICENSE) 228 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | 4 | 5 | ```console 6 | $ ghatm help 7 | ``` 8 | 9 | ## ghatm set 10 | 11 | ```console 12 | $ ghatm help set 13 | ``` 14 | 15 | ## ghatm completion 16 | 17 | ```console 18 | $ ghatm help completion 19 | ``` 20 | 21 | -------------------------------------------------------------------------------- /aqua/aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-amd64.tar.gz", 5 | "checksum": "E091107C4CA7E283902343BA3A09D14FB56B86E071EFFD461CE9D67193EF580E", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-darwin-arm64.tar.gz", 10 | "checksum": "90783FA092A0F64A4F7B7D419F3DA1F53207E300261773BABE962957240E9EA6", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-amd64.tar.gz", 15 | "checksum": "E55E0EB515936C0FBD178BCE504798A9BD2F0B191E5E357768B18FD5415EE541", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-linux-arm64.tar.gz", 20 | "checksum": "582EB73880F4408D7FB89F12B502D577BD7B0B63D8C681DA92BB6B9D934D4363", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-amd64.zip", 25 | "checksum": "FD7298019C76CF414AB083491F87F6C0A3E537ED6D727D6FF9135E503D6F9C32", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/golangci/golangci-lint/v2.1.6/golangci-lint-2.1.6-windows-arm64.zip", 30 | "checksum": "0DC38C44D8270A0ED3267BCD3FBDCD8384761D04D0FD2D53B63FC502F0F39722", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Darwin_all.tar.gz", 35 | "checksum": "82953B65C4B64E73B1077827663D97BF8E32592B4FC2CDB55C738BD484260A47", 36 | "algorithm": "sha256" 37 | }, 38 | { 39 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_arm64.tar.gz", 40 | "checksum": "574E83F5F0FC97803FF734C9342F8FD446D77E5E7CCAC53DEBF09B4A8DBDED80", 41 | "algorithm": "sha256" 42 | }, 43 | { 44 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Linux_x86_64.tar.gz", 45 | "checksum": "A066FCD713684ABED0D750D7559F1A5D794FA2FAA8E8F1AD2EECEC8C373668A7", 46 | "algorithm": "sha256" 47 | }, 48 | { 49 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_arm64.zip", 50 | "checksum": "EA19CAE5A322FEC6794252D3E9FE77D43201CC831D939964730E556BF3C1CC2C", 51 | "algorithm": "sha256" 52 | }, 53 | { 54 | "id": "github_release/github.com/goreleaser/goreleaser/v2.9.0/goreleaser_Windows_x86_64.zip", 55 | "checksum": "F56E85F8FD52875102DFC2B01DC07FC174486CAEBBAC7E3AA9F29B4F0057D495", 56 | "algorithm": "sha256" 57 | }, 58 | { 59 | "id": "github_release/github.com/int128/ghcp/v1.13.5/ghcp_darwin_amd64.zip", 60 | "checksum": "6728BD668888A64C71BF01D9AFBA373F38D353B79D181B1401A4E5E4B329289D", 61 | "algorithm": "sha256" 62 | }, 63 | { 64 | "id": "github_release/github.com/int128/ghcp/v1.13.5/ghcp_darwin_arm64.zip", 65 | "checksum": "6533FA0396CA6F4B9D3A74C2F0A5C4C89575A0D79F90684BAF6CD8B1511C95AC", 66 | "algorithm": "sha256" 67 | }, 68 | { 69 | "id": "github_release/github.com/int128/ghcp/v1.13.5/ghcp_linux_amd64.zip", 70 | "checksum": "3C808E566F0B663182AD5B5DD6E6B05DC8346610EA5613EA8C22AB19F47A4493", 71 | "algorithm": "sha256" 72 | }, 73 | { 74 | "id": "github_release/github.com/int128/ghcp/v1.13.5/ghcp_linux_arm64.zip", 75 | "checksum": "14068BF35B2ED187EE7D8DB854DF2E7913C2BBC981979D3F8AE533D058E35F16", 76 | "algorithm": "sha256" 77 | }, 78 | { 79 | "id": "github_release/github.com/int128/ghcp/v1.13.5/ghcp_windows_amd64.zip", 80 | "checksum": "1A90AD7927F720EB8996AE143DF5D7AA2DF70689B7B5B9074D6FD32C244D688E", 81 | "algorithm": "sha256" 82 | }, 83 | { 84 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_arm64.tar.gz", 85 | "checksum": "A7FBF41913CE5B6F1872D10C136139B7A849190F4F1F0DC1ED4BF74C636F22A2", 86 | "algorithm": "sha256" 87 | }, 88 | { 89 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Darwin_x86_64.tar.gz", 90 | "checksum": "056DD0F43ECCB8651FB976B43AA91A1D34B2A0C3934F216997774A7CBC1F7EB1", 91 | "algorithm": "sha256" 92 | }, 93 | { 94 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_arm64.tar.gz", 95 | "checksum": "BD0C4045B8F367F1CA6C0E7CFD80189CCD2A8CEAA22034ECBAD4AF0ACB3A3B82", 96 | "algorithm": "sha256" 97 | }, 98 | { 99 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Linux_x86_64.tar.gz", 100 | "checksum": "2C634DBC00BD4A86E4D4C47029D2AF9185FAB06643A9DF0AE10E7C4D644781B6", 101 | "algorithm": "sha256" 102 | }, 103 | { 104 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_arm64.tar.gz", 105 | "checksum": "2DFD2C151AFF8B7D2DFDFC44FB47706667806AEA92F4F8238932BB89A0461D4A", 106 | "algorithm": "sha256" 107 | }, 108 | { 109 | "id": "github_release/github.com/reviewdog/reviewdog/v0.20.3/reviewdog_0.20.3_Windows_x86_64.tar.gz", 110 | "checksum": "068726CA98BBEB5E47378AB0B630133741E17BA1FEB5654A24EC5E604446EDEF", 111 | "algorithm": "sha256" 112 | }, 113 | { 114 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_darwin_amd64.tar.gz", 115 | "checksum": "28E5DE5A05FC558474F638323D736D822FFF183D2D492F0AECB2B73CC44584F5", 116 | "algorithm": "sha256" 117 | }, 118 | { 119 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_darwin_arm64.tar.gz", 120 | "checksum": "2693315B9093AEACB4EBD91A993FEA54FC215057BF0DA2659056B4BC033873DB", 121 | "algorithm": "sha256" 122 | }, 123 | { 124 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz", 125 | "checksum": "023070A287CD8CCCD71515FEDC843F1985BF96C436B7EFFAECCE67290E7E0757", 126 | "algorithm": "sha256" 127 | }, 128 | { 129 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_linux_arm64.tar.gz", 130 | "checksum": "401942F9C24ED71E4FE71B76C7D638F66D8633575C4016EFD2977CE7C28317D0", 131 | "algorithm": "sha256" 132 | }, 133 | { 134 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_windows_amd64.zip", 135 | "checksum": "7F12F1801BCA3D480D67AAF7774F4C2A6359A3CA8EEBE382C95C10C9704AA731", 136 | "algorithm": "sha256" 137 | }, 138 | { 139 | "id": "github_release/github.com/rhysd/actionlint/v1.7.7/actionlint_1.7.7_windows_arm64.zip", 140 | "checksum": "76E9514CFAC18E5677AA04F3A89873C981F16A2F2353BB97372A86CD09B1F5A8", 141 | "algorithm": "sha256" 142 | }, 143 | { 144 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-amd64", 145 | "checksum": "D61CC50F6F32C2B63CB81CD8A935E5DD1BE8520D639242031A1102092BEE44D4", 146 | "algorithm": "sha256" 147 | }, 148 | { 149 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-darwin-arm64", 150 | "checksum": "780DA3654D9601367B0D54686AC65CB9716578610CABE292D725C7008DE4DB85", 151 | "algorithm": "sha256" 152 | }, 153 | { 154 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-amd64", 155 | "checksum": "1F6C194DD0891EB345B436BB71FF9F996768355F5E0CE02DDE88567029AC2188", 156 | "algorithm": "sha256" 157 | }, 158 | { 159 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-linux-arm64", 160 | "checksum": "080A998F9878F22DAFDB9AD54D5B2E2B8E7A38C53527250F9D89A6763A28D545", 161 | "algorithm": "sha256" 162 | }, 163 | { 164 | "id": "github_release/github.com/sigstore/cosign/v2.5.0/cosign-windows-amd64.exe", 165 | "checksum": "2345667CBCF60767C1A6F678755CBB7465367761084E9D2CBB59AE0CC1A94437", 166 | "algorithm": "sha256" 167 | }, 168 | { 169 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_amd64.tar.gz", 170 | "checksum": "C7533B3D95241A4E7DE61C7240892DE19DBAAFD26EF44AD8020BC5000E24594D", 171 | "algorithm": "sha256" 172 | }, 173 | { 174 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_darwin_arm64.tar.gz", 175 | "checksum": "141AD7EB4E3410864FD1D5D3E2920BC6C6163CE5B663A872283B0F58CFEA331F", 176 | "algorithm": "sha256" 177 | }, 178 | { 179 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_amd64.tar.gz", 180 | "checksum": "8DC530324176C3703C97E4FE355AF7ED82D4E6341219063856FD0A1594C6CC4B", 181 | "algorithm": "sha256" 182 | }, 183 | { 184 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_linux_arm64.tar.gz", 185 | "checksum": "8AA707E58144DD29CBC5A02EEE62842A0F54964F7CF6118B513A2FEAE1811C74", 186 | "algorithm": "sha256" 187 | }, 188 | { 189 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_amd64.zip", 190 | "checksum": "26BF4BAA495AF54456BACF5A16416AD5B6C756F5661ABD56E73C08CBCEAD65FE", 191 | "algorithm": "sha256" 192 | }, 193 | { 194 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.1/cmdx_windows_arm64.zip", 195 | "checksum": "4BB8F4F65EDCE1D3AAE86168075B2E2CD3377E9A72E5D5F51EE097BFABE5DEE2", 196 | "algorithm": "sha256" 197 | }, 198 | { 199 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.374.0/registry.yaml", 200 | "checksum": "619BDA08E2B9259FEFE5DF052EBB1FEDABE96C58CCC41444938FFA3F3EEB5828456A80CF1859695E37B4F74CD1A45C5AC516C82DFE5752D010AADE352EB222E0", 201 | "algorithm": "sha512" 202 | } 203 | ] 204 | } 205 | -------------------------------------------------------------------------------- /aqua/aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # aqua - Declarative CLI Version Manager 3 | # https://aquaproj.github.io/ 4 | checksum: 5 | enabled: true 6 | require_checksum: true 7 | registries: 8 | - type: standard 9 | ref: v4.374.0 # renovate: depName=aquaproj/aqua-registry 10 | packages: 11 | - import: imports/*.yaml 12 | -------------------------------------------------------------------------------- /aqua/imports/actionlint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: rhysd/actionlint@v1.7.7 3 | -------------------------------------------------------------------------------- /aqua/imports/cmdx.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/cmdx@v2.0.1 3 | -------------------------------------------------------------------------------- /aqua/imports/cosign.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: sigstore/cosign@v2.5.0 3 | -------------------------------------------------------------------------------- /aqua/imports/ghcp.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: int128/ghcp@v1.13.5 3 | -------------------------------------------------------------------------------- /aqua/imports/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: golangci/golangci-lint@v2.1.6 3 | -------------------------------------------------------------------------------- /aqua/imports/goreleser.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: goreleaser/goreleaser@v2.9.0 3 | -------------------------------------------------------------------------------- /aqua/imports/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: reviewdog/reviewdog@v0.20.3 3 | -------------------------------------------------------------------------------- /cmd/ghatm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/suzuki-shunsuke/ghatm/pkg/cli" 11 | "github.com/suzuki-shunsuke/ghatm/pkg/log" 12 | "github.com/suzuki-shunsuke/logrus-error/logerr" 13 | ) 14 | 15 | var ( 16 | version = "" 17 | commit = "" //nolint:gochecknoglobals 18 | date = "" //nolint:gochecknoglobals 19 | ) 20 | 21 | func main() { 22 | logE := log.New(version) 23 | if err := core(logE); err != nil { 24 | logerr.WithError(logE, err).Fatal("ghatm failed") 25 | } 26 | } 27 | 28 | func core(logE *logrus.Entry) error { 29 | runner := cli.Runner{ 30 | Stdin: os.Stdin, 31 | Stdout: os.Stdout, 32 | Stderr: os.Stderr, 33 | LDFlags: &cli.LDFlags{ 34 | Version: version, 35 | Commit: commit, 36 | Date: date, 37 | }, 38 | LogE: logE, 39 | } 40 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 41 | defer stop() 42 | return runner.Run(ctx, os.Args...) //nolint:wrapcheck 43 | } 44 | -------------------------------------------------------------------------------- /cmdx.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # the configuration file of cmdx - task runner 3 | # https://github.com/suzuki-shunsuke/cmdx 4 | tasks: 5 | - name: test 6 | short: t 7 | description: test 8 | usage: test 9 | script: go test ./... -race -covermode=atomic 10 | - name: vet 11 | short: v 12 | description: go vet 13 | usage: go vet 14 | script: go vet ./... 15 | - name: lint 16 | short: l 17 | description: lint the go code 18 | usage: lint the go code 19 | script: golangci-lint run 20 | - name: coverage 21 | short: c 22 | description: coverage test 23 | usage: coverage test 24 | script: "bash scripts/coverage.sh {{.target}}" 25 | args: 26 | - name: target 27 | - name: install 28 | short: i 29 | description: Build and install ghatm 30 | usage: Build and install ghatm by "go install" command 31 | script: go install ./cmd/ghatm 32 | - name: fmt 33 | description: Format GO codes 34 | usage: Format GO codes 35 | script: bash scripts/fmt.sh 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suzuki-shunsuke/ghatm 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/goccy/go-yaml v1.18.0 7 | github.com/google/go-cmp v0.7.0 8 | github.com/google/go-github/v72 v72.0.0 9 | github.com/mattn/go-colorable v0.1.14 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/spf13/afero v1.14.0 12 | github.com/suzuki-shunsuke/logrus-error v0.1.4 13 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 14 | github.com/urfave/cli/v3 v3.3.3 15 | golang.org/x/oauth2 v0.30.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/google/go-querystring v1.1.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | golang.org/x/sys v0.31.0 // indirect 23 | golang.org/x/text v0.23.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 5 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 6 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 8 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 9 | github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= 10 | github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 11 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 12 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 13 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 14 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 15 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 16 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 20 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 21 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 22 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 25 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 26 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | github.com/suzuki-shunsuke/logrus-error v0.1.4 h1:nWo98uba1fANHdZ9Y5pJ2RKs/PpVjrLzRp5m+mRb9KE= 28 | github.com/suzuki-shunsuke/logrus-error v0.1.4/go.mod h1:WsVvvw6SKSt08/fB2qbnsKIMJA4K1MYCUprqsBJbMiM= 29 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5 h1:ETCRtbqi2D0NTwGHBPTc1IRIa8mFPLt936J2FA1iy10= 30 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.0.5/go.mod h1:XzvaaJ7L21jH7CqwZj4FlYCRJYqiEUHMqiFp54je7/M= 31 | github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 32 | github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 33 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 34 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 35 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 38 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 39 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 40 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 41 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /pkg/cli/completion.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/urfave/cli/v3" 10 | ) 11 | 12 | type completionCommand struct { 13 | logE *logrus.Entry 14 | stdout io.Writer 15 | } 16 | 17 | func (cc *completionCommand) command() *cli.Command { 18 | // https://cli.urfave.org/v2/#bash-completion 19 | return &cli.Command{ 20 | Name: "completion", 21 | Usage: "Output shell completion script for bash, zsh, or fish", 22 | Description: `Output shell completion script for bash, zsh, or fish. 23 | Source the output to enable completion. 24 | 25 | e.g. 26 | 27 | .bash_profile 28 | 29 | source <(ghatm completion bash) 30 | 31 | .zprofile 32 | 33 | source <(ghatm completion zsh) 34 | 35 | fish 36 | 37 | ghatm completion fish > ~/.config/fish/completions/ghatm.fish 38 | `, 39 | Commands: []*cli.Command{ 40 | { 41 | Name: "bash", 42 | Usage: "Output shell completion script for bash", 43 | Action: cc.bashCompletionAction, 44 | }, 45 | { 46 | Name: "zsh", 47 | Usage: "Output shell completion script for zsh", 48 | Action: cc.zshCompletionAction, 49 | }, 50 | { 51 | Name: "fish", 52 | Usage: "Output shell completion script for fish", 53 | Action: cc.fishCompletionAction, 54 | }, 55 | }, 56 | } 57 | } 58 | 59 | func (cc *completionCommand) bashCompletionAction(context.Context, *cli.Command) error { 60 | // https://github.com/urfave/cli/blob/main/autocomplete/bash_autocomplete 61 | // https://github.com/urfave/cli/blob/c3f51bed6fffdf84227c5b59bd3f2e90683314df/autocomplete/bash_autocomplete#L5-L20 62 | fmt.Fprintln(cc.stdout, ` 63 | _cli_bash_autocomplete() { 64 | if [[ "${COMP_WORDS[0]}" != "source" ]]; then 65 | local cur opts base 66 | COMPREPLY=() 67 | cur="${COMP_WORDS[COMP_CWORD]}" 68 | if [[ "$cur" == "-"* ]]; then 69 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} ${cur} --generate-shell-completion ) 70 | else 71 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-shell-completion ) 72 | fi 73 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 74 | return 0 75 | fi 76 | } 77 | 78 | complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete ghatm`) 79 | return nil 80 | } 81 | 82 | func (cc *completionCommand) zshCompletionAction(context.Context, *cli.Command) error { 83 | // https://github.com/urfave/cli/blob/main/autocomplete/zsh_autocomplete 84 | // https://github.com/urfave/cli/blob/947f9894eef4725a1c15ed75459907b52dde7616/autocomplete/zsh_autocomplete 85 | fmt.Fprintln(cc.stdout, `#compdef ghatm 86 | 87 | _ghatm() { 88 | local -a opts 89 | local cur 90 | cur=${words[-1]} 91 | if [[ "$cur" == "-"* ]]; then 92 | opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-shell-completion)}") 93 | else 94 | opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}") 95 | fi 96 | 97 | if [[ "${opts[1]}" != "" ]]; then 98 | _describe 'values' opts 99 | else 100 | _files 101 | fi 102 | } 103 | 104 | if [ "$funcstack[1]" = "_ghatm" ]; then 105 | _ghatm "$@" 106 | else 107 | compdef _ghatm ghatm 108 | fi`) 109 | return nil 110 | } 111 | 112 | func (cc *completionCommand) fishCompletionAction(_ context.Context, c *cli.Command) error { 113 | s, err := c.ToFishCompletion() 114 | if err != nil { 115 | return fmt.Errorf("generate fish completion: %w", err) 116 | } 117 | fmt.Fprintln(cc.stdout, s) 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /pkg/cli/runner.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/helpall" 9 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/vcmd" 10 | "github.com/urfave/cli/v3" 11 | ) 12 | 13 | type Runner struct { 14 | Stdin io.Reader 15 | Stdout io.Writer 16 | Stderr io.Writer 17 | LDFlags *LDFlags 18 | LogE *logrus.Entry 19 | } 20 | 21 | type LDFlags struct { 22 | Version string 23 | Commit string 24 | Date string 25 | } 26 | 27 | func (r *Runner) Run(ctx context.Context, args ...string) error { 28 | return helpall.With(&cli.Command{ //nolint:wrapcheck 29 | Name: "ghatm", 30 | Usage: "", 31 | Version: r.LDFlags.Version + " (" + r.LDFlags.Commit + ")", 32 | Flags: []cli.Flag{ 33 | &cli.StringFlag{ 34 | Name: "log-level", 35 | Usage: "log level", 36 | }, 37 | &cli.StringFlag{ 38 | Name: "log-color", 39 | Usage: "Log color. One of 'auto' (default), 'always', 'never'", 40 | }, 41 | }, 42 | EnableShellCompletion: true, 43 | Commands: []*cli.Command{ 44 | (&setCommand{ 45 | logE: r.LogE, 46 | }).command(), 47 | (&completionCommand{ 48 | logE: r.LogE, 49 | stdout: r.Stdout, 50 | }).command(), 51 | vcmd.New(&vcmd.Command{ 52 | Name: "ghatm", 53 | Version: r.LDFlags.Version, 54 | SHA: r.LDFlags.Commit, 55 | }), 56 | }, 57 | }, nil).Run(ctx, args) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cli/set.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/afero" 11 | "github.com/suzuki-shunsuke/ghatm/pkg/controller/set" 12 | "github.com/suzuki-shunsuke/ghatm/pkg/log" 13 | "github.com/urfave/cli/v3" 14 | ) 15 | 16 | type setCommand struct { 17 | logE *logrus.Entry 18 | } 19 | 20 | func (rc *setCommand) command() *cli.Command { 21 | return &cli.Command{ 22 | Name: "set", 23 | Usage: "Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes", 24 | UsageText: "ghatm set", 25 | Description: `Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes. 26 | 27 | $ ghatm set 28 | `, 29 | Action: rc.action, 30 | Flags: []cli.Flag{ 31 | &cli.IntFlag{ 32 | Name: "timeout-minutes", 33 | Aliases: []string{"t"}, 34 | Usage: "The value of timeout-minutes", 35 | Value: 30, //nolint:mnd 36 | }, 37 | &cli.BoolFlag{ 38 | Name: "auto", 39 | Aliases: []string{"a"}, 40 | Usage: "Estimate the value of timeout-minutes automatically", 41 | }, 42 | &cli.StringFlag{ 43 | Name: "repo", 44 | Aliases: []string{"r"}, 45 | Usage: "GitHub Repository", 46 | Sources: cli.EnvVars("GITHUB_REPOSITORY"), 47 | }, 48 | &cli.IntFlag{ 49 | Name: "size", 50 | Aliases: []string{"s"}, 51 | Usage: "Data size", 52 | Value: 30, //nolint:mnd 53 | }, 54 | }, 55 | } 56 | } 57 | 58 | func (rc *setCommand) action(ctx context.Context, cmd *cli.Command) error { 59 | fs := afero.NewOsFs() 60 | logE := rc.logE 61 | log.SetLevel(cmd.String("log-level"), logE) 62 | log.SetColor(cmd.String("log-color"), logE) 63 | repo := cmd.String("repo") 64 | param := &set.Param{ 65 | Files: cmd.Args().Slice(), 66 | TimeoutMinutes: cmd.Int("timeout-minutes"), 67 | Auto: cmd.Bool("auto"), 68 | Size: cmd.Int("size"), 69 | } 70 | if param.Auto && repo == "" { 71 | return errors.New("the flag -auto requires the flag -repo") 72 | } 73 | if repo != "" { 74 | owner, repoName, ok := strings.Cut(repo, "/") 75 | if !ok { 76 | return fmt.Errorf("split the repository name: %s", repo) 77 | } 78 | param.RepoOwner = owner 79 | param.RepoName = repoName 80 | } 81 | return set.Set(ctx, rc.logE, fs, param) //nolint:wrapcheck 82 | } 83 | -------------------------------------------------------------------------------- /pkg/controller/set/estimate.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "math" 8 | "path/filepath" 9 | "regexp" 10 | "slices" 11 | "strings" 12 | "time" 13 | 14 | "github.com/sirupsen/logrus" 15 | "github.com/suzuki-shunsuke/ghatm/pkg/edit" 16 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 17 | "github.com/suzuki-shunsuke/logrus-error/logerr" 18 | ) 19 | 20 | func setNamePatterns(jobs map[string]*edit.Job, jobKeys map[string]struct{}, staticNames map[string]string, namePatterns map[string]*regexp.Regexp) error { 21 | for jobKey, job := range jobs { 22 | if _, ok := jobKeys[jobKey]; !ok { 23 | continue 24 | } 25 | name, nameRegexp, err := job.GetName(jobKey) 26 | if err != nil { 27 | return fmt.Errorf("get a job name: %w", logerr.WithFields(err, logrus.Fields{ 28 | "job_key": jobKey, 29 | })) 30 | } 31 | if nameRegexp == nil { 32 | staticNames[name] = jobKey 33 | continue 34 | } 35 | namePatterns[jobKey] = nameRegexp 36 | } 37 | return nil 38 | } 39 | 40 | func handleJob(logE *logrus.Entry, jobDurationMap map[string][]time.Duration, staticNames map[string]string, namePatterns map[string]*regexp.Regexp, job *github.WorkflowJob) { 41 | if jobKey, ok := staticNames[job.Name]; ok { 42 | logE.WithFields(logrus.Fields{ 43 | "job_name": job.Name, 44 | "job_key": jobKey, 45 | }).Debug("adding the job duration") 46 | a, ok := jobDurationMap[jobKey] 47 | if !ok { 48 | a = []time.Duration{} 49 | } 50 | a = append(a, job.Duration) 51 | jobDurationMap[jobKey] = a 52 | return 53 | } 54 | for jobKey, nameRegexp := range namePatterns { 55 | if !nameRegexp.MatchString(job.Name) { 56 | continue 57 | } 58 | logE.WithFields(logrus.Fields{ 59 | "job_name": job.Name, 60 | "job_key": jobKey, 61 | "job_name_pattern": nameRegexp.String(), 62 | }).Debug("adding the job duration") 63 | a, ok := jobDurationMap[jobKey] 64 | if !ok { 65 | a = []time.Duration{} 66 | } 67 | a = append(a, job.Duration) 68 | jobDurationMap[jobKey] = a 69 | return 70 | } 71 | logE.WithFields(logrus.Fields{ 72 | "job_name": job.Name, 73 | }).Debug("the job name doesn't match") 74 | } 75 | 76 | func handleWorkflowRun(ctx context.Context, logE *logrus.Entry, gh GitHub, param *Param, jobDurationMap map[string][]time.Duration, staticNames map[string]string, namePatterns map[string]*regexp.Regexp, runID int64) (bool, error) { 77 | jobOpts := &github.ListWorkflowJobsOptions{ 78 | Status: "success", 79 | } 80 | for range 10 { 81 | if isCompleted(logE, jobDurationMap, param.Size) { 82 | return true, nil 83 | } 84 | jobs, resp, err := gh.ListWorkflowJobs(ctx, logE, param.RepoOwner, param.RepoName, runID, jobOpts) 85 | if err != nil { 86 | return false, fmt.Errorf("list workflow jobs: %w", logerr.WithFields(err, logrus.Fields{ 87 | "workflow_run_id": runID, 88 | })) 89 | } 90 | logE.WithField("num_of_jobs", len(jobs)).Debug("list workflow jobs") 91 | for _, job := range jobs { 92 | logE := logE.WithFields(logrus.Fields{ 93 | "job_name": job.Name, 94 | "job_status": job.Status, 95 | "job_duration": job.Duration, 96 | }) 97 | if isCompleted(logE, jobDurationMap, param.Size) { 98 | logE.Debug("job has been completed") 99 | return true, nil 100 | } 101 | logE.Debug("handling the job") 102 | handleJob(logE, jobDurationMap, staticNames, namePatterns, job) 103 | } 104 | if resp.NextPage == 0 { 105 | break 106 | } 107 | jobOpts.Page = resp.NextPage 108 | } 109 | return false, nil 110 | } 111 | 112 | // getJobsByAPI gets each job's durations by the GitHub API. 113 | // It returns a map of job key and durations. 114 | func getJobsByAPI(ctx context.Context, logE *logrus.Entry, gh GitHub, param *Param, file string, wf *edit.Workflow, jobKeys map[string]struct{}) (map[string][]time.Duration, error) { 115 | // jobName -> jobKey 116 | staticNames := make(map[string]string, len(wf.Jobs)) 117 | // jobKey -> regular expression of job name 118 | namePatterns := make(map[string]*regexp.Regexp, len(wf.Jobs)) 119 | if err := setNamePatterns(wf.Jobs, jobKeys, staticNames, namePatterns); err != nil { 120 | return nil, err 121 | } 122 | logE.WithFields(logrus.Fields{ 123 | "static_names": strings.Join(slices.Collect(maps.Keys(staticNames)), ", "), 124 | "name_patterns": strings.Join(slices.Collect(maps.Keys(namePatterns)), ", "), 125 | }).Debug("static names and name patterns") 126 | 127 | jobDurationMap := make(map[string][]time.Duration, len(wf.Jobs)) 128 | for jobKey := range jobKeys { 129 | jobDurationMap[jobKey] = []time.Duration{} 130 | } 131 | 132 | runOpts := &github.ListWorkflowRunsOptions{ 133 | Status: "success", 134 | } 135 | loopSize := int(math.Ceil(float64(param.Size) * 3.0 / 100)) //nolint:mnd 136 | for range loopSize { 137 | runs, resp, err := gh.ListWorkflowRuns(ctx, param.RepoOwner, param.RepoName, file, runOpts) 138 | if err != nil { 139 | return nil, fmt.Errorf("list workflow runs: %w", err) 140 | } 141 | logE.WithField("num_of_runs", len(runs)).Debug("list workflow runs") 142 | for _, run := range runs { 143 | completed, err := handleWorkflowRun(ctx, logE, gh, param, jobDurationMap, staticNames, namePatterns, run.ID) 144 | if err != nil { 145 | return nil, err 146 | } 147 | if completed { 148 | return jobDurationMap, nil 149 | } 150 | } 151 | if resp.NextPage == 0 { 152 | return jobDurationMap, nil 153 | } 154 | runOpts.Page = resp.NextPage 155 | } 156 | return jobDurationMap, nil 157 | } 158 | 159 | func isCompleted(logE *logrus.Entry, jobDurationMap map[string][]time.Duration, size int) bool { 160 | for jobKey, durations := range jobDurationMap { 161 | if len(durations) < size { 162 | logE.WithFields(logrus.Fields{ 163 | "job_key": jobKey, 164 | "param_size": size, 165 | "num_of_durations": len(durations), 166 | }).Debug("the job hasn't been completed") 167 | return false 168 | } 169 | } 170 | return true 171 | } 172 | 173 | // estimateTimeout estimates each job's timeout-minutes. 174 | // It returns a map of job key and timeout-minutes. 175 | // If there is no job's duration, the job is excluded from the return value. 176 | func estimateTimeout(ctx context.Context, logE *logrus.Entry, gh GitHub, param *Param, file string, wf *edit.Workflow, jobKeys map[string]struct{}) (map[string]int, error) { 177 | fileName := filepath.Base(file) 178 | jobs, err := getJobsByAPI(ctx, logE, gh, param, fileName, wf, jobKeys) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | // Each job's timeout-minutes is `max(durations) + 10`. 184 | m := make(map[string]int, len(jobs)) 185 | for jobKey, durations := range jobs { 186 | if len(durations) == 0 { 187 | logE.WithField("job_key", jobKey).Warn("the job is ignored because the job wasn't executed") 188 | continue 189 | } 190 | maxDuration := slices.Max(durations) 191 | m[jobKey] = int(math.Ceil(maxDuration.Minutes())) + 10 //nolint:mnd 192 | } 193 | 194 | return m, nil 195 | } 196 | -------------------------------------------------------------------------------- /pkg/controller/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/spf13/afero" 8 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 9 | "github.com/suzuki-shunsuke/logrus-error/logerr" 10 | ) 11 | 12 | type Param struct { 13 | Files []string 14 | TimeoutMinutes int 15 | Auto bool 16 | RepoOwner string 17 | RepoName string 18 | Size int 19 | } 20 | 21 | func Set(ctx context.Context, logE *logrus.Entry, fs afero.Fs, param *Param) error { 22 | files := param.Files 23 | if len(files) == 0 { 24 | a, err := findWorkflows(fs) 25 | if err != nil { 26 | return err 27 | } 28 | files = a 29 | } 30 | 31 | var gh *github.Client 32 | if param.Auto { 33 | gh = github.NewClient(ctx) 34 | } 35 | 36 | for _, file := range files { 37 | logE := logE.WithField("workflow_file", file) 38 | logE.Info("handling the workflow file") 39 | if err := handleWorkflow(ctx, logE, fs, gh, file, param); err != nil { 40 | return logerr.WithFields(err, logrus.Fields{ //nolint:wrapcheck 41 | "workflow_file": file, 42 | }) 43 | } 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/controller/set/workflow.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/afero" 9 | "github.com/suzuki-shunsuke/ghatm/pkg/edit" 10 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type GitHub interface { 15 | ListWorkflowRuns(ctx context.Context, owner, repo, workflowFileName string, opts *github.ListWorkflowRunsOptions) ([]*github.WorkflowRun, *github.Response, error) 16 | ListWorkflowJobs(ctx context.Context, logE *logrus.Entry, owner, repo string, runID int64, opts *github.ListWorkflowJobsOptions) ([]*github.WorkflowJob, *github.Response, error) 17 | } 18 | 19 | func handleWorkflow(ctx context.Context, logE *logrus.Entry, fs afero.Fs, gh GitHub, file string, param *Param) error { 20 | content, err := afero.ReadFile(fs, file) 21 | if err != nil { 22 | return fmt.Errorf("read a file: %w", err) 23 | } 24 | 25 | wf := &edit.Workflow{} 26 | if err := yaml.Unmarshal(content, wf); err != nil { 27 | return fmt.Errorf("unmarshal a workflow file: %w", err) 28 | } 29 | if err := wf.Validate(); err != nil { 30 | return fmt.Errorf("validate a workflow: %w", err) 31 | } 32 | 33 | jobNames := edit.ListJobsWithoutTimeout(wf.Jobs) 34 | 35 | var timeouts map[string]int 36 | if param.Auto { 37 | tm, err := estimateTimeout(ctx, logE, gh, param, file, wf, jobNames) 38 | if err != nil { 39 | return err 40 | } 41 | timeouts = tm 42 | } 43 | 44 | after, err := edit.Edit(content, wf, timeouts, param.TimeoutMinutes) 45 | if err != nil { 46 | return fmt.Errorf("create a new workflow content: %w", err) 47 | } 48 | if after == nil { 49 | return nil 50 | } 51 | return writeWorkflow(fs, file, after) 52 | } 53 | 54 | func writeWorkflow(fs afero.Fs, file string, content []byte) error { 55 | stat, err := fs.Stat(file) 56 | if err != nil { 57 | return fmt.Errorf("get the workflow file stat: %w", err) 58 | } 59 | 60 | if err := afero.WriteFile(fs, file, content, stat.Mode()); err != nil { 61 | return fmt.Errorf("write the workflow file: %w", err) 62 | } 63 | return nil 64 | } 65 | 66 | func findWorkflows(fs afero.Fs) ([]string, error) { 67 | files, err := afero.Glob(fs, ".github/workflows/*.yml") 68 | if err != nil { 69 | return nil, fmt.Errorf("find .github/workflows/*.yml: %w", err) 70 | } 71 | files2, err := afero.Glob(fs, ".github/workflows/*.yaml") 72 | if err != nil { 73 | return nil, fmt.Errorf("find .github/workflows/*.yaml: %w", err) 74 | } 75 | return append(files, files2...), nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/edit/ast.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/goccy/go-yaml/ast" 8 | "github.com/goccy/go-yaml/parser" 9 | "github.com/sirupsen/logrus" 10 | "github.com/suzuki-shunsuke/logrus-error/logerr" 11 | ) 12 | 13 | type Position struct { 14 | JobKey string 15 | Line int 16 | Column int 17 | } 18 | 19 | func parseWorkflowAST(content []byte, jobNames map[string]struct{}) ([]*Position, error) { 20 | file, err := parser.ParseBytes(content, parser.ParseComments) 21 | if err != nil { 22 | return nil, fmt.Errorf("parse a workflow file as YAML: %w", err) 23 | } 24 | list := []*Position{} 25 | for _, doc := range file.Docs { 26 | arr, err := parseDocAST(doc, jobNames) 27 | if err != nil { 28 | return nil, err 29 | } 30 | if len(arr) == 0 { 31 | continue 32 | } 33 | list = append(list, arr...) 34 | } 35 | return list, nil 36 | } 37 | 38 | func parseDocAST(doc *ast.DocumentNode, jobNames map[string]struct{}) ([]*Position, error) { 39 | body, ok := doc.Body.(*ast.MappingNode) 40 | if !ok { 41 | return nil, errors.New("document body must be *ast.MappingNode") 42 | } 43 | // jobs: 44 | // jobName: 45 | // timeout-minutes: 10 46 | // steps: 47 | jobsNode := findJobsNode(body.Values) 48 | if jobsNode == nil { 49 | return nil, errors.New("the field 'jobs' is required") 50 | } 51 | return parseDocValue(jobsNode, jobNames) 52 | } 53 | 54 | func findJobsNode(values []*ast.MappingValueNode) *ast.MappingValueNode { 55 | for _, value := range values { 56 | key, ok := value.Key.(*ast.StringNode) 57 | if !ok { 58 | continue 59 | } 60 | if key.Value == "jobs" { 61 | return value 62 | } 63 | } 64 | return nil 65 | } 66 | 67 | func getMappingValueNodes(value *ast.MappingValueNode) ([]*ast.MappingValueNode, error) { 68 | switch node := value.Value.(type) { 69 | case *ast.MappingNode: 70 | return node.Values, nil 71 | case *ast.MappingValueNode: 72 | return []*ast.MappingValueNode{node}, nil 73 | } 74 | return nil, errors.New("value must be either a *ast.MappingNode or a *ast.MappingValueNode") 75 | } 76 | 77 | func parseDocValue(value *ast.MappingValueNode, jobNames map[string]struct{}) ([]*Position, error) { 78 | values, err := getMappingValueNodes(value) 79 | if err != nil { 80 | return nil, err 81 | } 82 | arr := make([]*Position, 0, len(values)) 83 | for _, job := range values { 84 | pos, err := parseJobAST(job, jobNames) 85 | if err != nil { 86 | return nil, err 87 | } 88 | if pos == nil { 89 | continue 90 | } 91 | arr = append(arr, pos) 92 | } 93 | return arr, nil 94 | } 95 | 96 | func parseJobAST(value *ast.MappingValueNode, jobNames map[string]struct{}) (*Position, error) { 97 | jobNameNode, ok := value.Key.(*ast.StringNode) 98 | if !ok { 99 | return nil, errors.New("job name must be a string") 100 | } 101 | jobName := jobNameNode.Value 102 | if _, ok := jobNames[jobName]; !ok { 103 | return nil, nil //nolint:nilnil 104 | } 105 | fields, err := getMappingValueNodes(value) 106 | if err != nil { 107 | return nil, logerr.WithFields(err, logrus.Fields{ //nolint:wrapcheck 108 | "job": jobName, 109 | }) 110 | } 111 | if len(fields) == 0 { 112 | return nil, logerr.WithFields(errors.New("job doesn't have any field"), logrus.Fields{ //nolint:wrapcheck 113 | "job": jobName, 114 | }) 115 | } 116 | firstValue := fields[0] 117 | pos := firstValue.Key.GetToken().Position 118 | return &Position{ 119 | JobKey: jobName, 120 | Line: pos.Line - 1, 121 | Column: pos.Column, 122 | }, nil 123 | } 124 | -------------------------------------------------------------------------------- /pkg/edit/edit.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func Edit(content []byte, wf *Workflow, timeouts map[string]int, timeout int) ([]byte, error) { 10 | jobNames := ListJobsWithoutTimeout(wf.Jobs) 11 | positions, err := parseWorkflowAST(content, jobNames) 12 | if err != nil { 13 | return nil, err 14 | } 15 | if len(positions) == 0 { 16 | return nil, nil 17 | } 18 | 19 | lines, err := insertTimeout(content, positions, timeouts, timeout) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return []byte(strings.Join(lines, "\n") + "\n"), nil 24 | } 25 | 26 | func ListJobsWithoutTimeout(jobs map[string]*Job) map[string]struct{} { 27 | m := make(map[string]struct{}, len(jobs)) 28 | for jobName, job := range jobs { 29 | if hasTimeout(job) { 30 | continue 31 | } 32 | m[jobName] = struct{}{} 33 | } 34 | return m 35 | } 36 | 37 | func hasTimeout(job *Job) bool { 38 | if job.TimeoutMinutes != nil || job.Uses != "" { 39 | return true 40 | } 41 | for _, step := range job.Steps { 42 | if step.TimeoutMinutes == nil { 43 | return false 44 | } 45 | } 46 | return true 47 | } 48 | 49 | func getTimeout(timeouts map[string]int, timeout int, jobKey string) int { 50 | if timeouts == nil { 51 | return timeout 52 | } 53 | if a, ok := timeouts[jobKey]; ok { 54 | return a 55 | } 56 | return -1 57 | } 58 | 59 | func insertTimeout(content []byte, positions []*Position, timeouts map[string]int, timeout int) ([]string, error) { 60 | reader := strings.NewReader(string(content)) 61 | scanner := bufio.NewScanner(reader) 62 | num := -1 63 | 64 | lines := []string{} 65 | pos := positions[0] 66 | lastPosIndex := len(positions) - 1 67 | posIndex := 0 68 | for scanner.Scan() { 69 | num++ 70 | line := scanner.Text() 71 | if pos.Line == num { 72 | indent := strings.Repeat(" ", pos.Column-1) 73 | if t := getTimeout(timeouts, timeout, pos.JobKey); t != -1 { 74 | lines = append(lines, indent+fmt.Sprintf("timeout-minutes: %d", t)) 75 | } 76 | if posIndex == lastPosIndex { 77 | pos.Line = -1 78 | } else { 79 | posIndex++ 80 | pos = positions[posIndex] 81 | } 82 | } 83 | lines = append(lines, line) 84 | } 85 | 86 | if err := scanner.Err(); err != nil { 87 | return nil, fmt.Errorf("scan a workflow file: %w", err) 88 | } 89 | return lines, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/edit/edit_internal_test.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestEdit(t *testing.T) { //nolint:gocognit,cyclop,funlen 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | content string 16 | result string 17 | isErr bool 18 | wf *Workflow 19 | timeouts map[string]int 20 | }{ 21 | { 22 | name: "normal", 23 | content: "normal.yaml", 24 | result: "normal_result.yaml", 25 | wf: &Workflow{ 26 | Jobs: map[string]*Job{ 27 | "actionlint": { 28 | Uses: "suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1", 29 | }, 30 | "foo": { 31 | TimeoutMinutes: 5, 32 | Steps: []*Step{ 33 | {}, 34 | }, 35 | }, 36 | "bar": { 37 | Steps: []*Step{ 38 | { 39 | TimeoutMinutes: 5, 40 | }, 41 | }, 42 | }, 43 | "zoo": { 44 | Steps: []*Step{ 45 | {}, 46 | }, 47 | }, 48 | "yoo": { 49 | Steps: []*Step{ 50 | {}, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "nochange", 58 | content: "nochange.yaml", 59 | wf: &Workflow{ 60 | Jobs: map[string]*Job{ 61 | "actionlint": { 62 | Uses: "suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1", 63 | }, 64 | "foo": { 65 | TimeoutMinutes: 5, 66 | Steps: []*Step{ 67 | {}, 68 | }, 69 | }, 70 | "bar": { 71 | Steps: []*Step{ 72 | { 73 | TimeoutMinutes: 5, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | }, 80 | { 81 | // The tool should recognize ${{ inputs.timeout }} in with-timeout job and add timeout to without-timeout job 82 | name: "reusable_workflow_timeout", 83 | content: "reusable_workflow_timeout.yaml", 84 | result: "reusable_workflow_timeout_result.yaml", 85 | wf: &Workflow{ 86 | Jobs: map[string]*Job{ 87 | "with-timeout": { 88 | TimeoutMinutes: "${{ inputs.timeout }}", // This should be detected as having a timeout via inputs 89 | Steps: []*Step{ 90 | {}, 91 | }, 92 | }, 93 | "without-timeout": { 94 | TimeoutMinutes: nil, // This should get a default timeout added 95 | Steps: []*Step{ 96 | {}, 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | } 103 | for _, d := range data { 104 | t.Run(d.name, func(t *testing.T) { 105 | t.Parallel() 106 | content, err := os.ReadFile(filepath.Join("testdata", d.content)) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | var expResult []byte 111 | if d.result != "" { 112 | content, err := os.ReadFile(filepath.Join("testdata", d.result)) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | expResult = content 117 | } 118 | result, err := Edit(content, d.wf, d.timeouts, 30) 119 | if err != nil { 120 | if d.isErr { 121 | return 122 | } 123 | t.Fatal(err) 124 | } 125 | if result == nil { 126 | if expResult == nil { 127 | return 128 | } 129 | t.Fatalf("wanted %v, got nil", string(expResult)) 130 | } 131 | if expResult == nil { 132 | t.Fatalf("wanted nil, got %v", string(result)) 133 | } 134 | if diff := cmp.Diff(string(expResult), string(result)); diff != "" { 135 | t.Fatal(diff) 136 | } 137 | }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /pkg/edit/testdata/invalid_jobs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | foo: null 6 | -------------------------------------------------------------------------------- /pkg/edit/testdata/jobs_not_found.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | -------------------------------------------------------------------------------- /pkg/edit/testdata/nochange.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | -------------------------------------------------------------------------------- /pkg/edit/testdata/normal.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | yoo: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | -------------------------------------------------------------------------------- /pkg/edit/testdata/normal_result.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | timeout-minutes: 30 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | yoo: 27 | timeout-minutes: 30 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | -------------------------------------------------------------------------------- /pkg/edit/testdata/reusable_workflow_timeout.yaml: -------------------------------------------------------------------------------- 1 | name: Test for reusable workflow timeout 2 | on: 3 | workflow_call: 4 | inputs: 5 | timeout: 6 | required: false 7 | type: number 8 | default: 2 9 | jobs: 10 | with-timeout: 11 | timeout-minutes: ${{ inputs.timeout }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Wait 15 | shell: bash 16 | run: | 17 | for i in {1..180}; do 18 | echo "${i}" 19 | sleep 1 20 | done 21 | without-timeout: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | -------------------------------------------------------------------------------- /pkg/edit/testdata/reusable_workflow_timeout_result.yaml: -------------------------------------------------------------------------------- 1 | name: Test for reusable workflow timeout 2 | on: 3 | workflow_call: 4 | inputs: 5 | timeout: 6 | required: false 7 | type: number 8 | default: 2 9 | jobs: 10 | with-timeout: 11 | timeout-minutes: ${{ inputs.timeout }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Wait 15 | shell: bash 16 | run: | 17 | for i in {1..180}; do 18 | echo "${i}" 19 | sleep 1 20 | done 21 | without-timeout: 22 | timeout-minutes: 30 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | -------------------------------------------------------------------------------- /pkg/edit/testdata/unmarshal_error.yaml: -------------------------------------------------------------------------------- 1 | "unmarshal error" 2 | -------------------------------------------------------------------------------- /pkg/edit/workflow.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/suzuki-shunsuke/logrus-error/logerr" 11 | ) 12 | 13 | type Workflow struct { 14 | Jobs map[string]*Job 15 | } 16 | 17 | type Job struct { 18 | Name string 19 | Steps []*Step 20 | Uses string 21 | TimeoutMinutes any `yaml:"timeout-minutes"` 22 | Strategy any 23 | } 24 | 25 | type Step struct { 26 | TimeoutMinutes any `yaml:"timeout-minutes"` 27 | } 28 | 29 | // foo (${{inputs.name}}) -> ^foo (.+?)$ 30 | 31 | var parameterRegexp = regexp.MustCompile(`\${{.+?}}`) 32 | 33 | func (j *Job) GetName(k string) (string, *regexp.Regexp, error) { 34 | if j.Strategy == nil { 35 | if j.Name == "" { 36 | return k, nil, nil 37 | } 38 | if !strings.Contains(j.Name, "${{") { 39 | return j.Name, nil, nil 40 | } 41 | r, err := regexp.Compile("^" + parameterRegexp.ReplaceAllLiteralString(j.Name, ".+") + "$") 42 | if err != nil { 43 | return "", nil, fmt.Errorf("convert a job name with parameters to a regular expression: %w", err) 44 | } 45 | return j.Name, r, nil 46 | } 47 | if j.Name == "" { 48 | r, err := regexp.Compile("^" + k + ` \(.*\)$`) 49 | if err != nil { 50 | return "", nil, fmt.Errorf("convert a job name with matrix to a regular expression: %w", err) 51 | } 52 | return k, r, nil 53 | } 54 | if !strings.Contains(j.Name, "${{") { 55 | r, err := regexp.Compile("^" + j.Name + ` \(.*\)$`) 56 | if err != nil { 57 | return "", nil, fmt.Errorf("convert a job name with matrix to a regular expression: %w", err) 58 | } 59 | return j.Name, r, nil 60 | } 61 | r, err := regexp.Compile("^" + parameterRegexp.ReplaceAllLiteralString(j.Name, ".+") + "$") 62 | if err != nil { 63 | return "", nil, fmt.Errorf("convert a job name with parameters to a regular expression: %w", err) 64 | } 65 | return j.Name, r, nil 66 | } 67 | 68 | func (w *Workflow) Validate() error { 69 | if w == nil { 70 | return errors.New("workflow is nil") 71 | } 72 | if len(w.Jobs) == 0 { 73 | return errors.New("jobs are empty") 74 | } 75 | for jobName, job := range w.Jobs { 76 | if err := job.Validate(); err != nil { 77 | return logerr.WithFields(err, logrus.Fields{"job": jobName}) //nolint:wrapcheck 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | func (j *Job) Validate() error { 84 | if j == nil { 85 | return errors.New("job is nil") 86 | } 87 | for _, step := range j.Steps { 88 | if err := step.Validate(); err != nil { 89 | return err 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | func (s *Step) Validate() error { 96 | if s == nil { 97 | return errors.New("step is nil") 98 | } 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/google/go-github/v72/github" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | type Response = github.Response 13 | 14 | func newGitHub(ctx context.Context) *github.Client { 15 | return github.NewClient(getHTTPClientForGitHub(ctx, getGitHubToken())) 16 | } 17 | 18 | func getGitHubToken() string { 19 | if token := os.Getenv("GHATM_GITHUB_TOKEN"); token != "" { 20 | return token 21 | } 22 | return os.Getenv("GITHUB_TOKEN") 23 | } 24 | 25 | func getHTTPClientForGitHub(ctx context.Context, token string) *http.Client { 26 | if token == "" { 27 | return http.DefaultClient 28 | } 29 | return oauth2.NewClient(ctx, oauth2.StaticTokenSource( 30 | &oauth2.Token{AccessToken: token}, 31 | )) 32 | } 33 | 34 | type ActionsService interface { 35 | ListWorkflowRunsByFileName(ctx context.Context, owner, repo, workflowFileName string, opts *github.ListWorkflowRunsOptions) (*github.WorkflowRuns, *github.Response, error) 36 | ListWorkflowJobs(ctx context.Context, owner, repo string, runID int64, opts *github.ListWorkflowJobsOptions) (*github.Jobs, *github.Response, error) 37 | } 38 | 39 | type Client struct { 40 | actions ActionsService 41 | } 42 | 43 | func NewClient(ctx context.Context) *Client { 44 | return &Client{ 45 | actions: newGitHub(ctx).Actions, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/github/workflow_job.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/go-github/v72/github" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type ListWorkflowJobsOptions struct { 12 | Date int 13 | Status string 14 | Page int 15 | } 16 | 17 | type WorkflowJob struct { 18 | ID int64 19 | Name string 20 | // The phase of the lifecycle that the job is currently in. 21 | // "queued", "in_progress", "completed", "waiting", "requested", "pending" 22 | Status string 23 | // The outcome of the job. 24 | // "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", 25 | Conclusion string 26 | Duration time.Duration 27 | } 28 | 29 | func (c *Client) ListWorkflowJobs(ctx context.Context, logE *logrus.Entry, owner, repo string, runID int64, opts *ListWorkflowJobsOptions) ([]*WorkflowJob, *github.Response, error) { 30 | o := &github.ListWorkflowJobsOptions{ 31 | ListOptions: github.ListOptions{ 32 | PerPage: 100, //nolint:mnd 33 | Page: opts.Page, 34 | }, 35 | } 36 | jobs, resp, err := c.actions.ListWorkflowJobs(ctx, owner, repo, runID, o) 37 | if err != nil { 38 | return nil, resp, err //nolint:wrapcheck 39 | } 40 | ret := make([]*WorkflowJob, 0, len(jobs.Jobs)) 41 | for _, job := range jobs.Jobs { 42 | s := job.GetStartedAt() 43 | started := s.GetTime() 44 | if started == nil { 45 | continue 46 | } 47 | j := &WorkflowJob{ 48 | ID: job.GetID(), 49 | Name: job.GetName(), 50 | Status: job.GetStatus(), 51 | Conclusion: job.GetConclusion(), 52 | Duration: job.GetCompletedAt().Sub(*started), 53 | } 54 | if j.Status != "completed" || j.Conclusion != "success" { 55 | logE.WithFields(logrus.Fields{ 56 | "status": j.Status, 57 | "conclusion": j.Conclusion, 58 | }).Debug("skip the job") 59 | continue 60 | } 61 | ret = append(ret, j) 62 | } 63 | return ret, resp, nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/github/workflow_run.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/v72/github" 7 | ) 8 | 9 | type ListWorkflowRunsOptions struct { 10 | Status string 11 | Page int 12 | } 13 | 14 | type WorkflowRun struct { 15 | ID int64 16 | // Can be one of: completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending 17 | Status string 18 | } 19 | 20 | func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo, workflowFileName string, opts *ListWorkflowRunsOptions) ([]*WorkflowRun, *github.Response, error) { 21 | o := &github.ListWorkflowRunsOptions{ 22 | ListOptions: github.ListOptions{ 23 | PerPage: 100, //nolint:mnd 24 | Page: opts.Page, 25 | }, 26 | Status: opts.Status, 27 | } 28 | runs, resp, err := c.actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowFileName, o) 29 | if err != nil { 30 | return nil, resp, err //nolint:wrapcheck 31 | } 32 | ret := make([]*WorkflowRun, 0, len(runs.WorkflowRuns)) 33 | for _, run := range runs.WorkflowRuns { 34 | ret = append(ret, &WorkflowRun{ 35 | ID: run.GetID(), 36 | Status: run.GetStatus(), 37 | }) 38 | } 39 | return ret, resp, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "runtime" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func New(version string) *logrus.Entry { 13 | logger := logrus.New() 14 | logger.Formatter = &logrus.TextFormatter{ 15 | DisableQuote: true, 16 | } 17 | return logger.WithFields(logrus.Fields{ 18 | "version": version, 19 | "program": "ghatm", 20 | "env": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 21 | }) 22 | } 23 | 24 | func SetLevel(level string, logE *logrus.Entry) { 25 | if level == "" { 26 | return 27 | } 28 | lvl, err := logrus.ParseLevel(level) 29 | if err != nil { 30 | logE.WithField("log_level", level).WithError(err).Error("the log level is invalid") 31 | return 32 | } 33 | logE.Logger.Level = lvl 34 | } 35 | 36 | func SetColor(color string, logE *logrus.Entry) { 37 | switch color { 38 | case "", "auto": 39 | return 40 | case "always": 41 | logrus.SetFormatter(&logrus.TextFormatter{ 42 | ForceColors: true, 43 | }) 44 | case "never": 45 | logrus.SetFormatter(&logrus.TextFormatter{ 46 | DisableColors: true, 47 | }) 48 | default: 49 | logE.WithField("log_color", color).Error("log_color is invalid") 50 | return 51 | } 52 | } 53 | 54 | func JSON(data any) any { 55 | return &jsonData{ 56 | data: data, 57 | } 58 | } 59 | 60 | type jsonData struct { 61 | data any 62 | } 63 | 64 | func (j *jsonData) String() string { 65 | buf := &bytes.Buffer{} 66 | encoder := json.NewEncoder(buf) 67 | encoder.SetIndent("", " ") 68 | if err := encoder.Encode(j.data); err != nil { 69 | return err.Error() 70 | } 71 | return buf.String() 72 | } 73 | -------------------------------------------------------------------------------- /pkg/log/log_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package log 5 | 6 | import ( 7 | "github.com/mattn/go-colorable" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func init() { 12 | logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true}) 13 | logrus.SetOutput(colorable.NewColorableStderr()) 14 | } 15 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "config:best-practices", 4 | "github>aquaproj/aqua-renovate-config#2.8.1", 5 | "github>aquaproj/aqua-renovate-config:file#2.8.1(aqua/imports/.*\\.ya?ml)", 6 | "github>lintnet/renovate-config#0.1.2", 7 | "github>suzuki-shunsuke/renovate-config#3.2.1", 8 | "github>suzuki-shunsuke/renovate-config:action-go-version#3.2.1", 9 | "github>suzuki-shunsuke/renovate-config:nolimit#3.2.1", 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | if [ $# -eq 0 ]; then 9 | target="$(go list ./... | fzf)" 10 | profile=.coverage/$target/coverage.txt 11 | mkdir -p .coverage/"$target" 12 | elif [ $# -eq 1 ]; then 13 | target=$1 14 | mkdir -p .coverage/"$target" 15 | profile=.coverage/$target/coverage.txt 16 | target=./$target 17 | else 18 | echo "too many arguments are given: $*" >&2 19 | exit 1 20 | fi 21 | 22 | go test "$target" -coverprofile="$profile" -covermode=atomic 23 | go tool cover -html="$profile" 24 | -------------------------------------------------------------------------------- /scripts/fmt.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | git ls-files | grep -E "\.go$" | xargs gofumpt -w 6 | -------------------------------------------------------------------------------- /scripts/generate-usage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | command_console() { 9 | echo '```console' 10 | echo "$ $*" 11 | "$@" 12 | echo '```' 13 | } 14 | 15 | commands() { 16 | for cmd in set completion; do 17 | echo " 18 | ## ghatm $cmd 19 | 20 | $(command_console ghatm help $cmd)" 21 | done 22 | } 23 | 24 | echo "# Usage 25 | 26 | 27 | 28 | $(command_console ghatm help) 29 | $(commands) 30 | " > USAGE.md 31 | -------------------------------------------------------------------------------- /testdata/after.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | timeout-minutes: 30 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | yoo: 27 | timeout-minutes: 30 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | -------------------------------------------------------------------------------- /testdata/before.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | yoo: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | --------------------------------------------------------------------------------