├── .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 |
--------------------------------------------------------------------------------