├── scripts └── entrypoint.sh ├── .gitignore ├── version └── version.go ├── testdata ├── test_workflow.yml ├── event_issue_opened.json └── event_issue_comment_opened.json ├── .ghdag.yml ├── Dockerfile ├── .octocov.yml ├── .github └── workflows │ ├── ghdag_workflow.yml │ └── ci.yml ├── task ├── action.go ├── yaml.go ├── task_test.go └── task.go ├── config └── config.go ├── erro └── erro.go ├── go.mod ├── LICENSE ├── gh ├── gh_test.go └── gh.go ├── Makefile ├── cmd ├── root.go ├── if.go ├── doRun.go ├── doLabels.go ├── doNotify.go ├── doAssignees.go ├── doReviewers.go ├── doComment.go ├── doState.go ├── check.go ├── do.go ├── run.go └── init.go ├── name ├── name.go └── name_test.go ├── main.go ├── env ├── env.go └── env_test.go ├── mock ├── mock_slk.go └── mock_gh.go ├── target └── target.go ├── .goreleaser.yml ├── runner ├── runner_test.go ├── actions.go ├── runner.go └── actions_test.go ├── slk └── slk.go ├── examples.md ├── CHANGELOG.md └── README.md /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -l 2 | 3 | ghdag $@ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.yml 2 | /ghdag 3 | /dist/ 4 | coverage.out 5 | .go-version 6 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Name for this 4 | const Name string = "ghdag" 5 | 6 | // Version for this 7 | var Version = "dev" 8 | -------------------------------------------------------------------------------- /testdata/test_workflow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tasks: 3 | - 4 | id: fetch-all-test 5 | if: true 6 | do: 7 | run: echo "." 8 | ok: 9 | run: echo "." 10 | ng: 11 | run: echo "F" 12 | -------------------------------------------------------------------------------- /.ghdag.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tasks: 3 | - 4 | id: set-assignees 5 | if: 'author == "k1LoW"' 6 | do: 7 | assignees: [k1LoW] 8 | ok: 9 | run: echo 'Set assignees' 10 | ng: 11 | run: echo 'Failed' 12 | name: Set assignees 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:buster-slim 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | curl \ 5 | git \ 6 | && apt-get clean \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | ENTRYPOINT ["/entrypoint.sh"] 10 | CMD [ "-h" ] 11 | 12 | COPY scripts/entrypoint.sh /entrypoint.sh 13 | RUN chmod +x /entrypoint.sh 14 | 15 | COPY ghdag_*.deb /tmp/ 16 | RUN dpkg -i /tmp/ghdag_*.deb 17 | -------------------------------------------------------------------------------- /.octocov.yml: -------------------------------------------------------------------------------- 1 | # generated by octocov init 2 | coverage: 3 | if: true 4 | codeToTestRatio: 5 | code: 6 | - '**/*.go' 7 | - '!**/*_test.go' 8 | test: 9 | - '**/*_test.go' 10 | testExecutionTime: 11 | if: true 12 | diff: 13 | datastores: 14 | - artifact://${GITHUB_REPOSITORY} 15 | comment: 16 | if: is_pull_request 17 | report: 18 | if: is_default_branch 19 | datastores: 20 | - artifact://${GITHUB_REPOSITORY} 21 | -------------------------------------------------------------------------------- /.github/workflows/ghdag_workflow.yml: -------------------------------------------------------------------------------- 1 | name: ghdag workflow 2 | on: 3 | issues: 4 | types: [opened] 5 | issue_comment: 6 | types: [created] 7 | pull_request: 8 | types: [opened] 9 | 10 | jobs: 11 | run-workflow: 12 | name: Run workflow 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | - name: Run ghdag 20 | uses: k1LoW/ghdag-action@main 21 | with: 22 | workflow-file: .ghdag.yml 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | job-test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go_version: [1.17] 16 | steps: 17 | - name: Set up Go ${{ matrix.go_version }} 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.go_version }} 21 | 22 | - name: Check out source code 23 | uses: actions/checkout@v2 24 | 25 | - name: Run tests 26 | run: make ci 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | DEBUG: 1 30 | 31 | - name: Run octocov 32 | uses: k1LoW/octocov-action@v0 33 | -------------------------------------------------------------------------------- /task/action.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | type ActionType int 4 | 5 | const ( 6 | ActionTypeDo ActionType = iota + 1 7 | ActionTypeOk 8 | ActionTypeNg 9 | ) 10 | 11 | var actionTypes = [...]string{"", "do", "ok", "ng"} 12 | 13 | func (t ActionType) String() string { 14 | return actionTypes[t] 15 | } 16 | 17 | type Action struct { 18 | Type ActionType `yaml:"-"` 19 | Run string `yaml:"run,omitempty"` 20 | Labels []string `yaml:"labels,omitempty"` 21 | Assignees []string `yaml:"assignees,omitempty"` 22 | Reviewers []string `yaml:"reviewers,omitempty"` 23 | Comment string `yaml:"comment,omitempty"` 24 | State string `yaml:"state,omitempty"` 25 | Notify string `yaml:"notify,omitempty"` 26 | Next []string `yaml:"next,omitempty"` 27 | } 28 | -------------------------------------------------------------------------------- /task/yaml.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "github.com/goccy/go-yaml" 5 | "github.com/k1LoW/ghdag/env" 6 | ) 7 | 8 | func (t *Task) UnmarshalYAML(data []byte) error { 9 | raw := &struct { 10 | Id string 11 | If string `yaml:"if,omitempty"` 12 | Do *Action 13 | Ok *Action `yaml:"ok,omitempty"` 14 | Ng *Action `yaml:"ng,omitempty"` 15 | Env env.Env `yaml:"env,omitempty"` 16 | Name string `yaml:"name,omitempty"` 17 | }{} 18 | if err := yaml.Unmarshal(data, raw); err != nil { 19 | return err 20 | } 21 | t.Id = raw.Id 22 | t.If = raw.If 23 | t.Do = raw.Do 24 | if t.Do != nil { 25 | t.Do.Type = ActionTypeDo 26 | } 27 | t.Ok = raw.Ok 28 | if t.Ok != nil { 29 | t.Ok.Type = ActionTypeOk 30 | } 31 | t.Ng = raw.Ng 32 | if t.Ng != nil { 33 | t.Ng.Type = ActionTypeNg 34 | } 35 | t.Env = raw.Env 36 | t.Name = raw.Name 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/k1LoW/ghdag/env" 8 | "github.com/k1LoW/ghdag/name" 9 | "github.com/k1LoW/ghdag/task" 10 | ) 11 | 12 | type Config struct { 13 | Tasks task.Tasks `yaml:"tasks"` 14 | Env env.Env `yaml:"env"` 15 | LinkedNames name.LinkedNames `yaml:"linkedNames"` 16 | } 17 | 18 | func New() *Config { 19 | return &Config{} 20 | } 21 | 22 | func (c *Config) CheckSyntax() error { 23 | valid := true 24 | errors := []string{} 25 | if ok, te := c.Tasks.CheckSyntax(); !ok { 26 | valid = false 27 | errors = append(errors, te...) 28 | } 29 | if ok, le := c.LinkedNames.CheckSyntax(); !ok { 30 | valid = false 31 | errors = append(errors, le...) 32 | } 33 | if !valid { 34 | return fmt.Errorf("invalid config syntax\n%s\n", strings.Join(errors, "\n")) 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /erro/erro.go: -------------------------------------------------------------------------------- 1 | package erro 2 | 3 | type AlreadyInStateError struct { 4 | err error 5 | } 6 | 7 | func (e AlreadyInStateError) Error() string { 8 | return e.err.Error() 9 | } 10 | 11 | // NewAlreadyInStateError ... 12 | func NewAlreadyInStateError(err error) AlreadyInStateError { 13 | return AlreadyInStateError{ 14 | err: err, 15 | } 16 | } 17 | 18 | type NotOpenError struct { 19 | err error 20 | } 21 | 22 | func (e NotOpenError) Error() string { 23 | return e.err.Error() 24 | } 25 | 26 | // NewNotOpenError ... 27 | func NewNotOpenError(err error) NotOpenError { 28 | return NotOpenError{ 29 | err: err, 30 | } 31 | } 32 | 33 | type NoReviewerError struct { 34 | err error 35 | } 36 | 37 | func (e NoReviewerError) Error() string { 38 | return e.err.Error() 39 | } 40 | 41 | // NewNoReviewerError ... 42 | func NewNoReviewerError(err error) NoReviewerError { 43 | return NoReviewerError{ 44 | err: err, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/k1LoW/ghdag 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/Songmu/prompter v0.4.0 7 | github.com/antonmedv/expr v1.8.9 8 | github.com/bxcodec/faker/v3 v3.6.0 9 | github.com/goccy/go-json v0.4.7 10 | github.com/goccy/go-yaml v1.8.8 11 | github.com/golang/mock v1.5.0 12 | github.com/google/go-cmp v0.5.5 13 | github.com/google/go-github/v33 v33.0.0 14 | github.com/hairyhenderson/go-codeowners v0.2.3-0.20201026200250-cdc7c0759690 15 | github.com/k1LoW/duration v1.1.0 16 | github.com/k1LoW/exec v0.2.0 17 | github.com/lestrrat-go/backoff/v2 v2.0.8 18 | github.com/pkg/errors v0.9.1 19 | github.com/rs/zerolog v1.20.0 20 | github.com/shurcooL/githubv4 v0.0.0-20201206200315-234843c633fa 21 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect 22 | github.com/slack-go/slack v0.8.1 23 | github.com/spf13/cobra v1.2.1 24 | golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2021 Ken'ichiro Oyama 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /gh/gh_test.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/k1LoW/ghdag/env" 9 | ) 10 | 11 | func TestDetectTargetNumber(t *testing.T) { 12 | tests := []struct { 13 | path string 14 | wantNumber int 15 | wantState string 16 | wantErr bool 17 | }{ 18 | {"event_issue_opened.json", 19, "open", false}, 19 | {"event_pull_request_opened.json", 20, "open", false}, 20 | {"event_issue_comment_opened.json", 20, "open", false}, 21 | } 22 | envCache := os.Environ() 23 | for _, tt := range tests { 24 | if err := env.Revert(envCache); err != nil { 25 | t.Fatal(err) 26 | } 27 | os.Setenv("GITHUB_EVENT_NAME", "test") 28 | os.Setenv("GITHUB_EVENT_PATH", filepath.Join(testdataDir(), tt.path)) 29 | got, err := DecodeGitHubEvent() 30 | if tt.wantErr && err != nil { 31 | continue 32 | } 33 | if err != nil { 34 | t.Error(err) 35 | } 36 | if got.Number != tt.wantNumber { 37 | t.Errorf("got %v\nwant %v", got.Number, tt.wantNumber) 38 | } 39 | if got.State != tt.wantState { 40 | t.Errorf("got %v\nwant %v", got.State, tt.wantState) 41 | } 42 | } 43 | } 44 | 45 | func testdataDir() string { 46 | wd, _ := os.Getwd() 47 | dir, _ := filepath.Abs(filepath.Join(filepath.Dir(wd), "testdata")) 48 | return dir 49 | } 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKG = github.com/k1LoW/ghdag 2 | COMMIT = $$(git describe --tags --always) 3 | OSNAME=${shell uname -s} 4 | ifeq ($(OSNAME),Darwin) 5 | DATE = $$(gdate --utc '+%Y-%m-%d_%H:%M:%S') 6 | else 7 | DATE = $$(date --utc '+%Y-%m-%d_%H:%M:%S') 8 | endif 9 | 10 | export GO111MODULE=on 11 | 12 | BUILD_LDFLAGS = -X $(PKG).commit=$(COMMIT) -X $(PKG).date=$(DATE) 13 | 14 | default: test 15 | 16 | ci: depsdev test integration sec 17 | 18 | test: 19 | mockgen -source gh/gh.go -destination mock/mock_gh.go -package mock 20 | mockgen -source slk/slk.go -destination mock/mock_slk.go -package mock 21 | go test ./... -coverprofile=coverage.out -covermode=count 22 | 23 | integration: build 24 | ./ghdag run testdata/test_workflow.yml 25 | 26 | sec: 27 | gosec ./... 28 | 29 | build: 30 | go build -ldflags="$(BUILD_LDFLAGS)" 31 | 32 | depsdev: 33 | go install github.com/golang/mock/mockgen@v1.6.0 34 | go install github.com/Songmu/ghch/cmd/ghch@v0.10.2 35 | go install github.com/Songmu/gocredits/cmd/gocredits@v0.2.0 36 | go install github.com/securego/gosec/v2/cmd/gosec@v2.9.6 37 | 38 | prerelease: 39 | git pull origin --tag 40 | ghch -w -N ${VER} 41 | gocredits . > CREDITS 42 | git add CHANGELOG.md CREDITS 43 | git commit -m'Bump up version number' 44 | git tag ${VER} 45 | 46 | release: 47 | git push origin --tag 48 | goreleaser --rm-dist 49 | 50 | .PHONY: default test 51 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | 27 | "github.com/k1LoW/ghdag/version" 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | // rootCmd represents the base command when called without any subcommands 32 | var rootCmd = &cobra.Command{ 33 | Use: "ghdag", 34 | Short: "ghdag is a tiny workflow engine for GitHub issue and pull request", 35 | Long: `ghdag is a tiny workflow engine for GitHub issue and pull request.`, 36 | Version: version.Version, 37 | SilenceUsage: true, 38 | } 39 | 40 | func Execute() { 41 | rootCmd.SetOut(os.Stdout) 42 | rootCmd.SetErr(os.Stderr) 43 | if err := rootCmd.Execute(); err != nil { 44 | os.Exit(1) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/if.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "os" 27 | "strings" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // ifCmd represents the if command 33 | var ifCmd = &cobra.Command{ 34 | Use: "if [CONDITION]", 35 | Short: "check condition", 36 | Long: `check condition.`, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | cond := strings.Join(args, " ") 39 | ctx := context.Background() 40 | r, i, err := initRunnerAndTask(ctx, number) 41 | if err != nil { 42 | return err 43 | } 44 | if !r.CheckIf(cond, i) { 45 | os.Exit(1) 46 | } 47 | return nil 48 | }, 49 | } 50 | 51 | func init() { 52 | rootCmd.AddCommand(ifCmd) 53 | ifCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 54 | } 55 | -------------------------------------------------------------------------------- /cmd/doRun.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "strings" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | // doRunCmd represents the doRun command 32 | var doRunCmd = &cobra.Command{ 33 | Use: "run [COMMAND...]", 34 | Short: "Execute command using `sh -c`", 35 | Long: "Execute command using `sh -c`.", 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | ctx := context.Background() 38 | r, _, err := initRunnerAndTask(ctx, number) 39 | if err != nil { 40 | return err 41 | } 42 | if err := r.PerformRunAction(ctx, nil, strings.Join(args, " ")); err != nil { 43 | return err 44 | } 45 | return nil 46 | }, 47 | } 48 | 49 | func init() { 50 | doRunCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 51 | } 52 | -------------------------------------------------------------------------------- /name/name.go: -------------------------------------------------------------------------------- 1 | package name 2 | 3 | import "fmt" 4 | 5 | type LinkedName struct { 6 | Github string 7 | Slack string 8 | } 9 | 10 | type LinkedNames []*LinkedName 11 | 12 | func (l LinkedNames) CheckSyntax() (bool, []string) { 13 | valid := true 14 | errors := []string{} 15 | if len(l) == 0 { 16 | return valid, errors 17 | } 18 | 19 | g := map[string]int{} 20 | s := map[string]int{} 21 | for i, n := range l { 22 | if j, ok := g[n.Github]; ok { 23 | valid = false 24 | errors = append(errors, fmt.Sprintf("'%s' is found in both linkedNames[%d].github and linkedNames[%d].github", n.Github, i, j)) 25 | } else { 26 | g[n.Github] = i 27 | } 28 | 29 | if j, ok := g[n.Slack]; ok { 30 | valid = false 31 | errors = append(errors, fmt.Sprintf("'%s' is found in both linkedNames[%d].slack and linkedNames[%d].slack", n.Slack, i, j)) 32 | } else { 33 | g[n.Slack] = i 34 | } 35 | } 36 | 37 | for n, i := range g { 38 | if j, ok := s[n]; ok { 39 | valid = false 40 | errors = append(errors, fmt.Sprintf("'%s' is found in both linkedNames[%d].github and linkedNames[%d].slack", n, i, j)) 41 | } 42 | } 43 | 44 | return valid, errors 45 | } 46 | 47 | func (l LinkedNames) ToGithubNames(in []string) []string { 48 | if len(l) == 0 { 49 | return in 50 | } 51 | m := map[string]string{} 52 | for _, n := range l { 53 | m[n.Slack] = n.Github 54 | } 55 | o := []string{} 56 | for _, n := range in { 57 | if v, ok := m[n]; ok { 58 | o = append(o, v) 59 | } else { 60 | o = append(o, n) 61 | } 62 | } 63 | return o 64 | } 65 | 66 | func (l LinkedNames) ToSlackNames(in []string) []string { 67 | if len(l) == 0 { 68 | return in 69 | } 70 | m := map[string]string{} 71 | for _, n := range l { 72 | m[n.Github] = n.Slack 73 | } 74 | o := []string{} 75 | for _, n := range in { 76 | if v, ok := m[n]; ok { 77 | o = append(o, v) 78 | } else { 79 | o = append(o, n) 80 | } 81 | } 82 | return o 83 | } 84 | -------------------------------------------------------------------------------- /cmd/doLabels.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // doLabelsCmd represents the doLabels command 31 | var doLabelsCmd = &cobra.Command{ 32 | Use: "labels [LABEL...]", 33 | Short: "Update the labels of the target issue or pull request", 34 | Long: "Update the labels of the target issue or pull request.", 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | ctx := context.Background() 37 | r, t, err := initRunnerAndTask(ctx, number) 38 | if err != nil { 39 | return err 40 | } 41 | if err := r.PerformLabelsAction(ctx, t, args); err != nil { 42 | return err 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | func init() { 49 | doLabelsCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 50 | } 51 | -------------------------------------------------------------------------------- /cmd/doNotify.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "strings" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | // doNotifyCmd represents the doNotify command 32 | var doNotifyCmd = &cobra.Command{ 33 | Use: "notify [MESSAGE]", 34 | Short: "Send notify message to slack channel", 35 | Long: "Send notify message to slack channel.", 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | message := strings.Join(args, " ") 38 | ctx := context.Background() 39 | r, _, err := initRunnerAndTask(ctx, number) 40 | if err != nil { 41 | return err 42 | } 43 | if err := r.PerformNotifyAction(ctx, nil, message); err != nil { 44 | return err 45 | } 46 | return nil 47 | }, 48 | } 49 | 50 | func init() { 51 | doNotifyCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 52 | } 53 | -------------------------------------------------------------------------------- /cmd/doAssignees.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // doAssigneesCmd represents the doAssignees command 31 | var doAssigneesCmd = &cobra.Command{ 32 | Use: "assignees [ASSIGNEE...]", 33 | Short: "Update the assignees of the target issue or pull request", 34 | Long: "Update the assignees of the target issue or pull request.", 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | ctx := context.Background() 37 | r, t, err := initRunnerAndTask(ctx, number) 38 | if err != nil { 39 | return err 40 | } 41 | if err := r.PerformAssigneesAction(ctx, t, args); err != nil { 42 | return err 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | func init() { 49 | doAssigneesCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 50 | } 51 | -------------------------------------------------------------------------------- /cmd/doReviewers.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // doReviewersCmd represents the doReviewers command 31 | var doReviewersCmd = &cobra.Command{ 32 | Use: "reviewers [REVIEWER...]", 33 | Short: "Update the reviewers of the target issue or pull request", 34 | Long: "Update the reviewers of the target issue or pull request.", 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | ctx := context.Background() 37 | r, t, err := initRunnerAndTask(ctx, number) 38 | if err != nil { 39 | return err 40 | } 41 | if err := r.PerformReviewersAction(ctx, t, args); err != nil { 42 | return err 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | func init() { 49 | doReviewersCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 50 | } 51 | -------------------------------------------------------------------------------- /cmd/doComment.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "strings" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | // doCommentCmd represents the doComment command 32 | var doCommentCmd = &cobra.Command{ 33 | Use: "comment [COMMENT]", 34 | Short: "Create the comment of the target issue or pull request", 35 | Long: "Create the comment of the target issue or pull request.", 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | comment := strings.Join(args, " ") 38 | ctx := context.Background() 39 | r, t, err := initRunnerAndTask(ctx, number) 40 | if err != nil { 41 | return err 42 | } 43 | if err := r.PerformCommentAction(ctx, t, comment); err != nil { 44 | return err 45 | } 46 | return nil 47 | }, 48 | } 49 | 50 | func init() { 51 | doCommentCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 52 | } 53 | -------------------------------------------------------------------------------- /cmd/doState.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // doStateCmd represents the doState command 31 | var doStateCmd = &cobra.Command{ 32 | Use: "state [STATE]", 33 | Short: "Change state of the target issue or pull request", 34 | Long: "Change state of the target issue or pull request.", 35 | Args: cobra.ExactValidArgs(1), 36 | ValidArgs: []string{"close", "merge"}, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | ctx := context.Background() 39 | r, t, err := initRunnerAndTask(ctx, number) 40 | if err != nil { 41 | return err 42 | } 43 | if err := r.PerformStateAction(ctx, t, args[0]); err != nil { 44 | return err 45 | } 46 | return nil 47 | }, 48 | } 49 | 50 | func init() { 51 | doStateCmd.Flags().IntVarP(&number, "number", "n", 0, "issue or pull request number") 52 | } 53 | -------------------------------------------------------------------------------- /task/task_test.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/goccy/go-yaml" 8 | "github.com/k1LoW/ghdag/env" 9 | ) 10 | 11 | func TestCheckSyntax(t *testing.T) { 12 | tests := []struct { 13 | in []byte 14 | env map[string]string 15 | wantOk bool 16 | }{ 17 | {[]byte(` 18 | id: task-id 19 | if: bug in labels 20 | do: 21 | assignees: [alice bob charlie] 22 | `), map[string]string{}, true}, 23 | {[]byte(` 24 | id: task-id 25 | if: bug in labels 26 | do: 27 | `), map[string]string{}, false}, 28 | {[]byte(` 29 | id: task-id 30 | if: bug in labels 31 | do: 32 | assignees: [alice bob charlie] 33 | comment: hello 34 | `), map[string]string{}, false}, 35 | {[]byte(` 36 | id: task-id 37 | if: bug in labels 38 | do: 39 | env: 40 | GITHUB_ASSIGNEES: alice bob charlie 41 | `), map[string]string{}, false}, 42 | {[]byte(` 43 | id: task-id 44 | if: bug in labels 45 | do: 46 | assignees: [] 47 | `), map[string]string{}, false}, 48 | {[]byte(` 49 | id: task-id 50 | if: bug in labels 51 | do: 52 | assignees: [] 53 | env: 54 | GITHUB_ASSIGNEES: alice bob charlie 55 | `), map[string]string{}, true}, 56 | {[]byte(` 57 | id: task-id 58 | if: bug in labels 59 | do: 60 | reviewers: [] 61 | env: 62 | GITHUB_REVIEWERS: alice bob charlie 63 | `), map[string]string{}, true}, 64 | {[]byte(` 65 | id: task-id 66 | if: bug in labels 67 | do: 68 | assignees: [] 69 | `), map[string]string{ 70 | "GITHUB_ASSIGNEES": "alice bob charlie", 71 | }, true}, 72 | {[]byte(` 73 | id: task-id 74 | if: bug in labels 75 | do: 76 | reviewers: [] 77 | `), map[string]string{ 78 | "GITHUB_REVIEWERS": "alice bob charlie", 79 | }, true}, 80 | } 81 | envCache := os.Environ() 82 | for _, tt := range tests { 83 | if err := env.Revert(envCache); err != nil { 84 | t.Fatal(err) 85 | } 86 | tsk := &Task{} 87 | if err := yaml.Unmarshal(tt.in, tsk); err != nil { 88 | t.Fatal(err) 89 | } 90 | for k, v := range tt.env { 91 | os.Setenv(k, v) 92 | } 93 | if ok, _ := tsk.CheckSyntax(); ok != tt.wantOk { 94 | t.Errorf("%s\ngot %v\nwant %v", tt.in, ok, tt.wantOk) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | "strings" 28 | "time" 29 | 30 | "github.com/k1LoW/ghdag/cmd" 31 | "github.com/k1LoW/ghdag/env" 32 | "github.com/rs/zerolog" 33 | "github.com/rs/zerolog/log" 34 | ) 35 | 36 | func main() { 37 | initLogger() 38 | cmd.Execute() 39 | } 40 | 41 | func initLogger() { 42 | if !env.GetenvAsBool("DEBUG") { 43 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 44 | } 45 | output := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339} 46 | output.FormatLevel = func(i interface{}) string { 47 | return strings.ToUpper(fmt.Sprintf("[%s]", i.(string)[0:4])) 48 | } 49 | output.FormatMessage = func(i interface{}) string { 50 | return fmt.Sprintf("%s", i) 51 | } 52 | output.FormatFieldName = func(i interface{}) string { 53 | return fmt.Sprintf("%s:", i) 54 | } 55 | output.FormatFieldValue = func(i interface{}) string { 56 | return strings.ToUpper(fmt.Sprintf("%s", i)) 57 | } 58 | log.Logger = zerolog.New(output).With().Timestamp().Logger() 59 | } 60 | -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type Env map[string]string 11 | 12 | func (e Env) Setenv() error { 13 | for k, v := range e { 14 | if err := os.Setenv(k, os.ExpandEnv(v)); err != nil { 15 | return err 16 | } 17 | } 18 | return nil 19 | } 20 | 21 | func Revert(envCache []string) error { 22 | for _, e := range os.Environ() { 23 | splitted := strings.Split(e, "=") 24 | if err := os.Unsetenv(splitted[0]); err != nil { 25 | return err 26 | } 27 | } 28 | for _, e := range envCache { 29 | splitted := strings.Split(e, "=") 30 | if err := os.Setenv(splitted[0], splitted[1]); err != nil { 31 | return err 32 | } 33 | } 34 | return nil 35 | } 36 | 37 | func Split(in string) ([]string, error) { 38 | if in == "" { 39 | return []string{}, nil 40 | } 41 | sq := strings.Count(in, "'") 42 | if sq > 0 && (sq%2 == 0) { 43 | in = strings.Replace(in, `'`, `"`, -1) 44 | } 45 | r := csv.NewReader(strings.NewReader(in)) 46 | if !strings.Contains(in, ",") { 47 | r.Comma = ' ' 48 | } 49 | c, err := r.Read() 50 | if err != nil { 51 | return nil, err 52 | } 53 | res := []string{} 54 | for _, s := range c { 55 | trimed := strings.Trim(s, " ") 56 | if trimed == "" { 57 | continue 58 | } 59 | res = append(res, trimed) 60 | } 61 | return res, nil 62 | } 63 | 64 | func Join(in []string) string { 65 | formated := []string{} 66 | for _, s := range in { 67 | if strings.Contains(s, " ") { 68 | s = fmt.Sprintf(`"%s"`, s) 69 | } 70 | formated = append(formated, s) 71 | } 72 | return strings.Join(formated, " ") 73 | } 74 | 75 | func EnvMap() map[string]string { 76 | m := map[string]string{} 77 | for _, kv := range os.Environ() { 78 | if !strings.Contains(kv, "=") { 79 | continue 80 | } 81 | parts := strings.SplitN(kv, "=", 2) 82 | k := parts[0] 83 | if len(parts) < 2 { 84 | m[k] = "" 85 | continue 86 | } 87 | m[k] = parts[1] 88 | } 89 | return m 90 | } 91 | 92 | func GetenvAsBool(k string) bool { 93 | if os.Getenv(k) == "" || strings.ToLower(os.Getenv(k)) == "false" || os.Getenv(k) == "0" { 94 | return false 95 | } 96 | return true 97 | } 98 | -------------------------------------------------------------------------------- /mock/mock_slk.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: slk/slk.go 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockSlkClient is a mock of SlkClient interface. 15 | type MockSlkClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockSlkClientMockRecorder 18 | } 19 | 20 | // MockSlkClientMockRecorder is the mock recorder for MockSlkClient. 21 | type MockSlkClientMockRecorder struct { 22 | mock *MockSlkClient 23 | } 24 | 25 | // NewMockSlkClient creates a new mock instance. 26 | func NewMockSlkClient(ctrl *gomock.Controller) *MockSlkClient { 27 | mock := &MockSlkClient{ctrl: ctrl} 28 | mock.recorder = &MockSlkClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockSlkClient) EXPECT() *MockSlkClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // GetMentionLinkByName mocks base method. 38 | func (m *MockSlkClient) GetMentionLinkByName(ctx context.Context, name string) (string, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "GetMentionLinkByName", ctx, name) 41 | ret0, _ := ret[0].(string) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // GetMentionLinkByName indicates an expected call of GetMentionLinkByName. 47 | func (mr *MockSlkClientMockRecorder) GetMentionLinkByName(ctx, name interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMentionLinkByName", reflect.TypeOf((*MockSlkClient)(nil).GetMentionLinkByName), ctx, name) 50 | } 51 | 52 | // PostMessage mocks base method. 53 | func (m_2 *MockSlkClient) PostMessage(ctx context.Context, m string) error { 54 | m_2.ctrl.T.Helper() 55 | ret := m_2.ctrl.Call(m_2, "PostMessage", ctx, m) 56 | ret0, _ := ret[0].(error) 57 | return ret0 58 | } 59 | 60 | // PostMessage indicates an expected call of PostMessage. 61 | func (mr *MockSlkClientMockRecorder) PostMessage(ctx, m interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostMessage", reflect.TypeOf((*MockSlkClient)(nil).PostMessage), ctx, m) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "io/ioutil" 27 | "path/filepath" 28 | 29 | "github.com/goccy/go-yaml" 30 | "github.com/k1LoW/ghdag/config" 31 | "github.com/k1LoW/ghdag/version" 32 | "github.com/pkg/errors" 33 | "github.com/rs/zerolog/log" 34 | "github.com/spf13/cobra" 35 | ) 36 | 37 | // checkCmd represents the check command 38 | var checkCmd = &cobra.Command{ 39 | Use: "check", 40 | Short: "Check syntax of workflow file", 41 | Long: `Check syntax of workflow file.`, 42 | Args: cobra.ExactArgs(1), 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | log.Info().Msg(fmt.Sprintf("%s version %s", version.Name, version.Version)) 45 | b, err := ioutil.ReadFile(filepath.Clean(args[0])) 46 | if err != nil { 47 | return errors.WithStack(err) 48 | } 49 | c := &config.Config{} 50 | 51 | if err := yaml.Unmarshal(b, c); err != nil { 52 | return err 53 | } 54 | 55 | if err := c.CheckSyntax(); err != nil { 56 | return err 57 | } 58 | 59 | log.Info().Msg(fmt.Sprintf("the workflow file %s syntax is ok", args[0])) 60 | 61 | return nil 62 | }, 63 | } 64 | 65 | func init() { 66 | rootCmd.AddCommand(checkCmd) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/do.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "errors" 27 | "os" 28 | 29 | "github.com/k1LoW/ghdag/runner" 30 | "github.com/k1LoW/ghdag/target" 31 | "github.com/spf13/cobra" 32 | ) 33 | 34 | var number int 35 | 36 | // doCmd represents the do command 37 | var doCmd = &cobra.Command{ 38 | Use: "do", 39 | Short: "Do action", 40 | Long: `Do action.`, 41 | } 42 | 43 | func init() { 44 | rootCmd.AddCommand(doCmd) 45 | doCmd.AddCommand(doRunCmd) 46 | doCmd.AddCommand(doLabelsCmd) 47 | doCmd.AddCommand(doAssigneesCmd) 48 | doCmd.AddCommand(doReviewersCmd) 49 | doCmd.AddCommand(doCommentCmd) 50 | doCmd.AddCommand(doStateCmd) 51 | doCmd.AddCommand(doNotifyCmd) 52 | } 53 | 54 | func initRunnerAndTask(ctx context.Context, number int) (*runner.Runner, *target.Target, error) { 55 | if os.Getenv("GITHUB_EVENT_NAME") == "" && number == 0 { 56 | return nil, nil, errors.New("env GITHUB_EVENT_NAME is not set. --number required") 57 | } 58 | r, err := runner.New(nil) 59 | if err != nil { 60 | return nil, nil, err 61 | } 62 | if err := r.InitClients(); err != nil { 63 | return nil, nil, err 64 | } 65 | t, err := r.FetchTarget(ctx, number) 66 | if err != nil { 67 | return nil, nil, err 68 | } 69 | return r, t, nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "fmt" 27 | "io/ioutil" 28 | "path/filepath" 29 | 30 | "github.com/goccy/go-yaml" 31 | "github.com/k1LoW/ghdag/config" 32 | "github.com/k1LoW/ghdag/runner" 33 | "github.com/k1LoW/ghdag/version" 34 | "github.com/pkg/errors" 35 | "github.com/rs/zerolog/log" 36 | "github.com/spf13/cobra" 37 | ) 38 | 39 | // runCmd represents the run command 40 | var runCmd = &cobra.Command{ 41 | Use: "run", 42 | Short: "Run workflow", 43 | Long: `Run workflow.`, 44 | Args: cobra.ExactArgs(1), 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | log.Info().Msg(fmt.Sprintf("%s version %s", version.Name, version.Version)) 47 | b, err := ioutil.ReadFile(filepath.Clean(args[0])) 48 | if err != nil { 49 | return errors.WithStack(err) 50 | } 51 | c := &config.Config{} 52 | 53 | if err := yaml.Unmarshal(b, c); err != nil { 54 | return err 55 | } 56 | 57 | if err := c.CheckSyntax(); err != nil { 58 | return err 59 | } 60 | 61 | r, err := runner.New(c) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | ctx := context.Background() 67 | 68 | if err := r.Run(ctx); err != nil { 69 | return err 70 | } 71 | 72 | return nil 73 | }, 74 | } 75 | 76 | func init() { 77 | rootCmd.AddCommand(runCmd) 78 | } 79 | -------------------------------------------------------------------------------- /env/env_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | envCache := os.Environ() 13 | 14 | m.Run() 15 | 16 | _ = Revert(envCache) 17 | } 18 | 19 | func TestSetenv(t *testing.T) { 20 | tests := []struct { 21 | before map[string]string 22 | in Env 23 | want map[string]string 24 | }{ 25 | { 26 | map[string]string{ 27 | "TOKEN": "abcdef", 28 | }, 29 | Env{ 30 | "GHDAG_TOKEN": "${TOKEN}", 31 | }, 32 | map[string]string{ 33 | "TOKEN": "abcdef", 34 | "GHDAG_TOKEN": "abcdef", 35 | }, 36 | }, 37 | { 38 | map[string]string{ 39 | "TOKEN": "abcdef", 40 | }, 41 | Env{ 42 | "GHDAG_TOKEN": "zzz${TOKEN}zzz", 43 | }, 44 | map[string]string{ 45 | "TOKEN": "abcdef", 46 | "GHDAG_TOKEN": "zzzabcdefzzz", 47 | }, 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | clearEnv() 53 | for k, v := range tt.before { 54 | os.Setenv(k, v) 55 | } 56 | 57 | tt.in.Setenv() 58 | 59 | after := EnvMap() 60 | 61 | if len(after) != len(tt.want) { 62 | t.Errorf("got %v\nwant %v", len(after), len(tt.want)) 63 | } 64 | 65 | for k, v := range tt.want { 66 | got, ok := after[k] 67 | if !ok { 68 | t.Errorf("got %v\nwant %v", ok, true) 69 | } 70 | if got != v { 71 | t.Errorf("got %v\nwant %v", got, v) 72 | } 73 | } 74 | } 75 | } 76 | 77 | func TestSplit(t *testing.T) { 78 | tests := []struct { 79 | in string 80 | want []string 81 | }{ 82 | {"bug", []string{"bug"}}, 83 | {"bug question", []string{"bug", "question"}}, 84 | {"bug question", []string{"bug", "question"}}, 85 | {"bug,question", []string{"bug", "question"}}, 86 | {"bug, question", []string{"bug", "question"}}, 87 | {"bug, question", []string{"bug", "question"}}, 88 | {`bug "help wanted"`, []string{"bug", "help wanted"}}, 89 | {`bug 'help wanted'`, []string{"bug", "help wanted"}}, 90 | {"", []string{}}, 91 | } 92 | for _, tt := range tests { 93 | got, err := Split(tt.in) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | if diff := cmp.Diff(got, tt.want, nil); diff != "" { 98 | t.Errorf("%s", diff) 99 | } 100 | got2, err := Split(Join(got)) 101 | if diff := cmp.Diff(got, got2, nil); diff != "" { 102 | t.Errorf("%s", diff) 103 | } 104 | } 105 | } 106 | 107 | func clearEnv() { 108 | for _, e := range os.Environ() { 109 | splitted := strings.Split(e, "=") 110 | os.Unsetenv(splitted[0]) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /target/target.go: -------------------------------------------------------------------------------- 1 | package target 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/goccy/go-json" 7 | ) 8 | 9 | // Target is Issue or Pull request 10 | type Target struct { 11 | Number int `json:"number"` 12 | State string `json:"state"` 13 | Title string `json:"title"` 14 | Body string `json:"body"` 15 | URL string `json:"url"` 16 | Author string `json:"author"` 17 | Labels []string `json:"labels"` 18 | Assignees []string `json:"assignees"` 19 | Reviewers []string `json:"reviewers"` 20 | CodeOwners []string `json:"code_owners"` 21 | ReviewersWhoApproved []string `json:"reviewers_who_approved"` 22 | CodeOwnersWhoApproved []string `json:"code_owners_who_approved"` 23 | IsIssue bool `json:"is_issue"` 24 | IsPullRequest bool `json:"is_pull_request"` 25 | IsApproved bool `json:"is_approved"` 26 | IsReviewRequired bool `json:"is_review_required"` 27 | IsChangeRequested bool `json:"is_change_requested"` 28 | Mergeable bool `json:"mergeable"` 29 | ChangedFiles int `json:"changed_files"` 30 | HoursElapsedSinceCreated int `json:"hours_elapsed_since_created"` 31 | HoursElapsedSinceUpdated int `json:"hours_elapsed_since_updated"` 32 | NumberOfComments int `json:"number_of_comments"` 33 | LatestCommentAuthor string `json:"latest_comment_author"` 34 | LatestCommentBody string `json:"latest_comment_body"` 35 | NumberOfConsecutiveComments int `json:"-"` 36 | 37 | Login string `json:"login"` 38 | } 39 | 40 | func (t *Target) NoCodeOwnerReviewers() []string { 41 | nr := []string{} 42 | for _, r := range t.Reviewers { 43 | if contains(t.CodeOwners, r) { 44 | continue 45 | } 46 | nr = append(nr, r) 47 | } 48 | return nr 49 | } 50 | 51 | func (t *Target) Dump() map[string]interface{} { 52 | b, _ := json.Marshal(t) 53 | v := map[string]interface{}{} 54 | _ = json.Unmarshal(b, &v) 55 | return v 56 | } 57 | 58 | type Targets map[int]*Target 59 | 60 | func (targets Targets) MaxDigits() int { 61 | digits := 0 62 | for _, t := range targets { 63 | if digits < len(fmt.Sprintf("%d", t.Number)) { 64 | digits = len(fmt.Sprintf("%d", t.Number)) 65 | } 66 | } 67 | return digits 68 | } 69 | 70 | func contains(s []string, e string) bool { 71 | for _, v := range s { 72 | if e == v { 73 | return true 74 | } 75 | } 76 | return false 77 | } 78 | -------------------------------------------------------------------------------- /name/name_test.go: -------------------------------------------------------------------------------- 1 | package name 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestCheckSyntax(t *testing.T) { 10 | tests := []struct { 11 | names LinkedNames 12 | want bool 13 | }{ 14 | { 15 | LinkedNames{}, 16 | true, 17 | }, 18 | { 19 | LinkedNames{ 20 | &LinkedName{ 21 | Github: "bob", 22 | Slack: "bob_marly", 23 | }, 24 | }, 25 | true, 26 | }, 27 | { 28 | LinkedNames{ 29 | &LinkedName{ 30 | Github: "bob", 31 | Slack: "bob", 32 | }, 33 | }, 34 | false, 35 | }, 36 | { 37 | LinkedNames{ 38 | &LinkedName{ 39 | Github: "bob", 40 | Slack: "bob_marly", 41 | }, 42 | &LinkedName{ 43 | Github: "bob", 44 | Slack: "bob_dylan", 45 | }, 46 | }, 47 | false, 48 | }, 49 | { 50 | LinkedNames{ 51 | &LinkedName{ 52 | Github: "bob", 53 | Slack: "bob_marly", 54 | }, 55 | &LinkedName{ 56 | Github: "bob_marly", 57 | Slack: "bob_dylan", 58 | }, 59 | }, 60 | false, 61 | }, 62 | } 63 | for _, tt := range tests { 64 | got, _ := tt.names.CheckSyntax() 65 | if got != tt.want { 66 | t.Errorf("got %v\nwant %v", got, tt.want) 67 | } 68 | } 69 | } 70 | 71 | func TestLinkedNames(t *testing.T) { 72 | tests := []struct { 73 | names LinkedNames 74 | in []string 75 | wantGithub []string 76 | wantSlack []string 77 | }{ 78 | { 79 | LinkedNames{}, 80 | []string{"alice", "bob", "charlie"}, 81 | []string{"alice", "bob", "charlie"}, 82 | []string{"alice", "bob", "charlie"}, 83 | }, 84 | { 85 | LinkedNames{ 86 | &LinkedName{ 87 | Github: "bob", 88 | Slack: "bob_marly", 89 | }, 90 | }, 91 | []string{"alice", "bob", "charlie"}, 92 | []string{"alice", "bob", "charlie"}, 93 | []string{"alice", "bob_marly", "charlie"}, 94 | }, 95 | { 96 | LinkedNames{ 97 | &LinkedName{ 98 | Github: "bob", 99 | Slack: "bob_marly", 100 | }, 101 | }, 102 | []string{"alice", "bob_marly", "charlie"}, 103 | []string{"alice", "bob", "charlie"}, 104 | []string{"alice", "bob_marly", "charlie"}, 105 | }, 106 | { 107 | LinkedNames{ 108 | &LinkedName{ 109 | Github: "bob", 110 | Slack: "bob_marly", 111 | }, 112 | }, 113 | []string{"alice_liddel", "bob_marly", "charlie_sheen"}, 114 | []string{"alice_liddel", "bob", "charlie_sheen"}, 115 | []string{"alice_liddel", "bob_marly", "charlie_sheen"}, 116 | }, 117 | } 118 | for _, tt := range tests { 119 | gotGithub := tt.names.ToGithubNames(tt.in) 120 | if diff := cmp.Diff(gotGithub, tt.wantGithub, nil); diff != "" { 121 | t.Errorf("%s", diff) 122 | } 123 | gotSlack := tt.names.ToSlackNames(tt.in) 124 | if diff := cmp.Diff(gotSlack, tt.wantSlack, nil); diff != "" { 125 | t.Errorf("%s", diff) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | - go mod tidy 5 | builds: 6 | - 7 | id: ghdag-linux 8 | env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | goarch: 13 | - amd64 14 | ldflags: 15 | - -s -w -X github.com/k1LoW/ghdag.version={{.Version}} -X github.com/k1LoW/ghdag.commit={{.FullCommit}} -X github.com/k1LoW/ghdag.date={{.Date}} -X github.com/k1LoW/ghdag/version.Version={{.Version}} 16 | - 17 | id: ghdag-darwin-windows 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - darwin 22 | - windows 23 | goarch: 24 | - amd64 25 | ldflags: 26 | - -s -w -X github.com/k1LoW/ghdag.version={{.Version}} -X github.com/k1LoW/ghdag.commit={{.FullCommit}} -X github.com/k1LoW/ghdag.date={{.Date}} -X github.com/k1LoW/ghdag/version.Version={{.Version}} 27 | - 28 | id: ghdag-darwin-arm64 29 | env: 30 | - CGO_ENABLED=0 31 | goos: 32 | - darwin 33 | goarch: 34 | - arm64 35 | ldflags: 36 | - -s -w -X github.com/k1LoW/ghdag.version={{.Version}} -X github.com/k1LoW/ghdag.commit={{.FullCommit}} -X github.com/k1LoW/ghdag.date={{.Date}} -X github.com/k1LoW/ghdag/version.Version={{.Version}} 37 | archives: 38 | - 39 | id: ghdag-archive 40 | name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 41 | format_overrides: 42 | - goos: darwin 43 | format: zip 44 | files: 45 | - CREDITS 46 | - README.md 47 | - CHANGELOG.md 48 | checksum: 49 | name_template: 'checksums.txt' 50 | snapshot: 51 | name_template: "{{ .Version }}-next" 52 | changelog: 53 | skip: true 54 | dockers: 55 | - 56 | goos: linux 57 | goarch: amd64 58 | image_templates: 59 | - 'ghcr.io/k1low/ghdag:v{{ .Version }}' 60 | - 'ghcr.io/k1low/ghdag:latest' 61 | dockerfile: Dockerfile 62 | build_flag_templates: 63 | - "--pull" 64 | - "--label=org.opencontainers.image.created={{.Date}}" 65 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 66 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 67 | - "--label=org.opencontainers.image.version={{.Version}}" 68 | - "--label=org.opencontainers.image.source=https://github.com/k1LoW/ghdag" 69 | extra_files: 70 | - scripts/entrypoint.sh 71 | brews: 72 | - 73 | name: ghdag 74 | tap: 75 | owner: k1LoW 76 | name: homebrew-tap 77 | commit_author: 78 | name: k1LoW 79 | email: k1lowxb@gmail.com 80 | homepage: https://github.com/k1LoW/ghdag 81 | description: ghdag is a tiny workflow engine for GitHub issue and pull request. 82 | license: MIT 83 | nfpms: 84 | - id: ghdag-nfpms 85 | file_name_template: "{{ .ProjectName }}_{{ .Version }}-1_{{ .Arch }}" 86 | builds: 87 | - ghdag-linux 88 | homepage: https://github.com/k1LoW/ghdag 89 | maintainer: Ken'ichiro Oyama 90 | description: ghdag is a tiny workflow engine for GitHub issue and pull request. 91 | license: MIT 92 | formats: 93 | - apk 94 | - deb 95 | - rpm 96 | bindir: /usr/bin 97 | epoch: 1 98 | -------------------------------------------------------------------------------- /task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/k1LoW/ghdag/env" 8 | ) 9 | 10 | type Task struct { 11 | Id string 12 | If string `yaml:"if,omitempty"` 13 | Do *Action 14 | Ok *Action `yaml:"ok,omitempty"` 15 | Ng *Action `yaml:"ng,omitempty"` 16 | Env env.Env `yaml:"env,omitempty"` 17 | Name string `yaml:"name,omitempty"` 18 | } 19 | 20 | type Tasks []*Task 21 | 22 | func (tasks Tasks) Find(id string) (*Task, error) { 23 | for _, t := range tasks { 24 | if t.Id == id { 25 | return t, nil 26 | } 27 | } 28 | return nil, fmt.Errorf("not found task: %s", id) 29 | } 30 | 31 | func (tasks Tasks) MaxLengthID() int { 32 | length := 0 33 | for _, t := range tasks { 34 | if length < len(t.Id) { 35 | length = len(t.Id) 36 | } 37 | } 38 | return length 39 | } 40 | 41 | func (t *Task) CheckSyntax() (bool, []string) { 42 | valid := true 43 | prefix := fmt.Sprintf("[%s] ", t.Id) 44 | errors := []string{} 45 | if t.Do != nil { 46 | v, e := t.CheckActionSyntax(t.Do) 47 | if !v { 48 | valid = false 49 | errors = append(errors, e...) 50 | } 51 | } else { 52 | valid = false 53 | errors = append(errors, fmt.Sprintf("%snot found `do:` action", prefix)) 54 | } 55 | if t.Ok != nil { 56 | v, e := t.CheckActionSyntax(t.Ok) 57 | if !v { 58 | valid = false 59 | errors = append(errors, e...) 60 | } 61 | } 62 | if t.Ng != nil { 63 | v, e := t.CheckActionSyntax(t.Ng) 64 | if !v { 65 | valid = false 66 | errors = append(errors, e...) 67 | } 68 | } 69 | return valid, errors 70 | } 71 | 72 | func (t *Task) CheckActionSyntax(a *Action) (bool, []string) { 73 | valid := true 74 | prefix := fmt.Sprintf("[%s] ", t.Id) 75 | errors := []string{} 76 | c := 0 77 | if a.Run != "" { 78 | c++ 79 | } 80 | if len(a.Labels) > 0 { 81 | c++ 82 | } 83 | as, _ := t.Env["GITHUB_ASSIGNEES"] 84 | if len(a.Assignees) > 0 || (a.Assignees != nil && as != "") || (a.Assignees != nil && os.Getenv("GITHUB_ASSIGNEES") != "") { 85 | c++ 86 | } 87 | rs, _ := t.Env["GITHUB_REVIEWERS"] 88 | if len(a.Reviewers) > 0 || (a.Reviewers != nil && rs != "") || (a.Reviewers != nil && os.Getenv("GITHUB_REVIEWERS") != "") { 89 | c++ 90 | } 91 | if a.Comment != "" { 92 | c++ 93 | } 94 | if a.State != "" { 95 | c++ 96 | } 97 | if a.Notify != "" { 98 | c++ 99 | } 100 | if len(a.Next) > 0 { 101 | c++ 102 | } 103 | if c != 1 { 104 | valid = false 105 | errors = append(errors, fmt.Sprintf("%sinvalid `%s:` action (want 1 definition, got %d)", prefix, a.Type, c)) 106 | } 107 | return valid, errors 108 | } 109 | 110 | func (tasks Tasks) CheckSyntax() (bool, []string) { 111 | valid := true 112 | errors := []string{} 113 | ids := map[string]struct{}{} 114 | for _, t := range tasks { 115 | if v, e := t.CheckSyntax(); !v { 116 | valid = false 117 | errors = append(errors, e...) 118 | } 119 | if _, exist := ids[t.Id]; exist { 120 | valid = false 121 | errors = append(errors, fmt.Sprintf("duplicate task id: %s", t.Id)) 122 | } 123 | ids[t.Id] = struct{}{} 124 | } 125 | return valid, errors 126 | } 127 | -------------------------------------------------------------------------------- /runner/runner_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/bxcodec/faker/v3" 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/k1LoW/ghdag/env" 12 | "github.com/k1LoW/ghdag/target" 13 | ) 14 | 15 | func TestCheckIf(t *testing.T) { 16 | envCache := os.Environ() 17 | defer func() { 18 | if err := env.Revert(envCache); err != nil { 19 | t.Fatal(err) 20 | } 21 | }() 22 | tests := []struct { 23 | cond string 24 | env map[string]string 25 | want bool 26 | }{ 27 | { 28 | "", 29 | map[string]string{}, 30 | false, 31 | }, 32 | { 33 | "github.event_name == 'issues'", 34 | map[string]string{ 35 | "GITHUB_EVENT_NAME": "issues", 36 | }, 37 | true, 38 | }, 39 | { 40 | "'bug' in caller_action_labels_updated", 41 | map[string]string{ 42 | "GHDAG_ACTION_LABELS_UPDATED": "bug question", 43 | }, 44 | true, 45 | }, 46 | { 47 | `github.event_name 48 | == 49 | 'issues' 50 | && 51 | 'question' 52 | in 53 | caller_action_labels_updated`, 54 | map[string]string{ 55 | "GITHUB_EVENT_NAME": "issues", 56 | "GHDAG_ACTION_LABELS_UPDATED": "bug question", 57 | }, 58 | true, 59 | }, 60 | { 61 | `github.event_name == 'issues' 62 | && github.event.action == 'opened' 63 | && github.event.issue.state == 'open'`, 64 | map[string]string{ 65 | "GITHUB_EVENT_NAME": "issues", 66 | "GITHUB_EVENT_PATH": filepath.Join(testdataDir(), "event_issue_opened.json"), 67 | }, 68 | true, 69 | }, 70 | { 71 | `github.event_name == 'pull_request' 72 | || github.event.invalid_value == 'xxxxxxx'`, 73 | map[string]string{ 74 | "GITHUB_EVENT_NAME": "pull_request", 75 | "GITHUB_EVENT_PATH": filepath.Join(testdataDir(), "event_pull_request_opened.json"), 76 | }, 77 | true, 78 | }, 79 | { 80 | "env.GITHUB_EVENT_NAME == 'issues'", 81 | map[string]string{ 82 | "GITHUB_EVENT_NAME": "issues", 83 | }, 84 | true, 85 | }, 86 | } 87 | for _, tt := range tests { 88 | if err := env.Revert(envCache); err != nil { 89 | t.Fatal(err) 90 | } 91 | for k, v := range tt.env { 92 | os.Setenv(k, v) 93 | } 94 | r, err := New(nil) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | i := &target.Target{} 99 | if err := faker.FakeData(i); err != nil { 100 | t.Fatal(err) 101 | } 102 | got := r.CheckIf(tt.cond, i) 103 | if got != tt.want { 104 | t.Errorf("if(%s) got %v\nwant %v", tt.cond, got, tt.want) 105 | } 106 | } 107 | } 108 | 109 | func TestSampleByEnv(t *testing.T) { 110 | tests := []struct { 111 | env int 112 | want int 113 | }{ 114 | {3, 3}, 115 | {2, 2}, 116 | {4, 3}, 117 | {0, 0}, 118 | } 119 | r, err := New(nil) 120 | if err != nil { 121 | t.Fatal(err) 122 | } 123 | for _, tt := range tests { 124 | r.initSeed() 125 | in := []string{"alice", "bob", "charlie"} 126 | envKey := "TEST_SAMPLE_BY_ENV" 127 | os.Setenv(envKey, fmt.Sprintf("%d", tt.env)) 128 | got, err := r.sample(in, envKey) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | if len(got) != tt.want { 133 | t.Errorf("got %v\nwant %v", got, tt.want) 134 | } 135 | } 136 | } 137 | 138 | func TestSampleByEnvWithSameSeed(t *testing.T) { 139 | tests := []struct { 140 | enable bool 141 | diff bool 142 | }{ 143 | {false, true}, 144 | {true, false}, 145 | } 146 | r, err := New(nil) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | for _, tt := range tests { 151 | if err := r.revertEnv(); err != nil { 152 | t.Fatal(err) 153 | } 154 | if err := os.Setenv("GHDAG_SAMPLE_WITH_SAME_SEED", fmt.Sprintf("%t", tt.enable)); err != nil { 155 | t.Fatal(err) 156 | } 157 | a := []string{} 158 | b := []string{} 159 | for i := 0; i < 100; i++ { 160 | a = append(a, fmt.Sprintf("%d", i)) 161 | b = append(b, fmt.Sprintf("%d", i)) 162 | } 163 | envKey := "TEST_SAMPLE_BY_ENV" 164 | os.Setenv(envKey, "99") 165 | 166 | r.initSeed() 167 | got, err := r.sample(a, envKey) 168 | if err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | r.initSeed() 173 | got2, err := r.sample(b, envKey) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | if diff := cmp.Diff(got, got2, nil); (diff != "") != tt.diff { 178 | t.Error("sample error") 179 | } 180 | } 181 | } 182 | 183 | func TestUnique(t *testing.T) { 184 | tests := []struct { 185 | in []string 186 | want []string 187 | }{ 188 | {[]string{}, []string{}}, 189 | {[]string{"a", "b", "a"}, []string{"a", "b"}}, 190 | {[]string{"a", "b", "a", "a"}, []string{"a", "b"}}, 191 | {[]string{"b", "b", "a", "a", "b"}, []string{"b", "a"}}, 192 | } 193 | for _, tt := range tests { 194 | got := unique(tt.in) 195 | if diff := cmp.Diff(got, tt.want, nil); diff != "" { 196 | t.Errorf("%s", diff) 197 | } 198 | } 199 | } 200 | 201 | func testdataDir() string { 202 | wd, _ := os.Getwd() 203 | dir, _ := filepath.Abs(filepath.Join(filepath.Dir(wd), "testdata")) 204 | return dir 205 | } 206 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Ken'ichiro Oyama 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | "path/filepath" 28 | "text/template" 29 | 30 | "github.com/Songmu/prompter" 31 | "github.com/k1LoW/ghdag/version" 32 | "github.com/rs/zerolog/log" 33 | "github.com/spf13/cobra" 34 | ) 35 | 36 | // initCmd represents the init command 37 | var initCmd = &cobra.Command{ 38 | Use: "init", 39 | Short: "Generate a workflow file for ghdag", 40 | Long: `Generate a workflow file for ghdag.`, 41 | Args: cobra.ExactArgs(1), 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | log.Info().Msg(fmt.Sprintf("%s version %s", version.Name, version.Version)) 44 | n := args[0] 45 | path := fmt.Sprintf("%s.yml", n) 46 | log.Info().Msg(fmt.Sprintf("Creating %s", path)) 47 | if _, err := os.Lstat(path); err == nil { 48 | return fmt.Errorf("%s already exist", path) 49 | } 50 | dir := filepath.Dir(path) 51 | if _, err := os.Lstat(dir); err != nil { 52 | yn := prompter.YN(fmt.Sprintf("%s does not exist. Do you create it?", dir), true) 53 | if !yn { 54 | return nil 55 | } 56 | if err := os.MkdirAll(dir, 0750); err != nil { 57 | return err 58 | } 59 | } 60 | if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { 61 | return err 62 | } 63 | file, err := os.Create(filepath.Clean(path)) 64 | if err != nil { 65 | return err 66 | } 67 | defer func() { 68 | _ = file.Close() 69 | }() 70 | 71 | ts := `--- 72 | # generate by ghdag init 73 | tasks: 74 | - 75 | id: set-question-label 76 | if: 'is_issue && len(labels) == 0 && title endsWith "?"' 77 | do: 78 | labels: [question] 79 | ok: 80 | run: echo 'Set labels' 81 | ng: 82 | run: echo 'failed' 83 | name: Set 'question' label 84 | ` 85 | tmpl := template.Must(template.New("init").Parse(ts)) 86 | tmplData := map[string]interface{}{} 87 | if err := tmpl.Execute(file, tmplData); err != nil { 88 | return err 89 | } 90 | yn := prompter.YN("Do you generate a workflow YAML file for GitHub Actions?", true) 91 | if !yn { 92 | return nil 93 | } 94 | { 95 | dir := filepath.Join(".github", "workflows") 96 | if _, err := os.Lstat(dir); err != nil { 97 | yn := prompter.YN(fmt.Sprintf("%s does not exist. Do you create it?", dir), true) 98 | if !yn { 99 | return nil 100 | } 101 | if err := os.MkdirAll(dir, 0750); err != nil { 102 | return err 103 | } 104 | } 105 | path := filepath.Join(dir, "ghdag_workflow.yml") 106 | log.Info().Msg(fmt.Sprintf("Creating %s", path)) 107 | if _, err := os.Lstat(path); err == nil { 108 | return fmt.Errorf("%s already exist", path) 109 | } 110 | file, err := os.Create(filepath.Clean(path)) 111 | if err != nil { 112 | return err 113 | } 114 | defer func() { 115 | _ = file.Close() 116 | }() 117 | 118 | ts := `--- 119 | # generate by ghdag init 120 | name: ghdag workflow 121 | on: 122 | issues: 123 | types: [opened] 124 | issue_comment: 125 | types: [created] 126 | pull_request: 127 | types: [opened] 128 | 129 | jobs: 130 | run-workflow: 131 | name: Run workflow 132 | runs-on: ubuntu-latest 133 | container: ghcr.io/k1low/ghdag:latest 134 | steps: 135 | - name: Checkout 136 | uses: actions/checkout@v2 137 | with: 138 | token: {{ "${{ secrets.GITHUB_TOKEN }}" }} 139 | - name: Run ghdag 140 | run: ghdag run {{ .Name }}.yml 141 | env: 142 | GITHUB_TOKEN: {{ "${{ secrets.GITHUB_TOKEN }}" }} 143 | ` 144 | tmpl := template.Must(template.New("workflow").Parse(ts)) 145 | tmplData := map[string]interface{}{ 146 | "Name": n, 147 | } 148 | if err := tmpl.Execute(file, tmplData); err != nil { 149 | return err 150 | } 151 | } 152 | return nil 153 | }, 154 | } 155 | 156 | func init() { 157 | rootCmd.AddCommand(initCmd) 158 | } 159 | -------------------------------------------------------------------------------- /slk/slk.go: -------------------------------------------------------------------------------- 1 | package slk 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/slack-go/slack" 11 | ) 12 | 13 | type SlkClient interface { 14 | PostMessage(ctx context.Context, m string) error 15 | GetMentionLinkByName(ctx context.Context, name string) (string, error) 16 | } 17 | 18 | type Client struct { 19 | client *slack.Client 20 | channelCache map[string]slack.Channel 21 | userCache map[string]slack.User 22 | userGroupCache map[string]slack.UserGroup 23 | } 24 | 25 | func NewClient() (*Client, error) { 26 | c := &Client{ 27 | channelCache: map[string]slack.Channel{}, 28 | userCache: map[string]slack.User{}, 29 | userGroupCache: map[string]slack.UserGroup{}, 30 | } 31 | if os.Getenv("SLACK_API_TOKEN") != "" { 32 | c.client = slack.New(os.Getenv("SLACK_API_TOKEN")) 33 | } 34 | return c, nil 35 | } 36 | 37 | func (c *Client) PostMessage(ctx context.Context, m string) error { 38 | switch { 39 | case c.client != nil: 40 | return c.postMessage(ctx, m) 41 | case os.Getenv("SLACK_API_TOKEN") != "": 42 | // temporary 43 | c.client = slack.New(os.Getenv("SLACK_API_TOKEN")) 44 | err := c.postMessage(ctx, m) 45 | c.client = nil 46 | return err 47 | case os.Getenv("SLACK_WEBHOOK_URL") != "": 48 | return c.postWebbookMessage(ctx, m) 49 | default: 50 | return errors.New("not found environment for Slack: SLACK_API_TOKEN or SLACK_WEBHOOK_URL") 51 | } 52 | return nil 53 | } 54 | 55 | func (c *Client) postMessage(ctx context.Context, m string) error { 56 | if os.Getenv("SLACK_CHANNEL") == "" { 57 | return errors.New("not found environment for Slack: SLACK_CHANNEL") 58 | } 59 | channel := os.Getenv("SLACK_CHANNEL") 60 | channelID, err := c.getChannelIDByName(ctx, channel) 61 | if err != nil { 62 | return err 63 | } 64 | opts := []slack.MsgOption{ 65 | slack.MsgOptionBlocks(buildBlocks(m)...), 66 | } 67 | 68 | if username := os.Getenv("SLACK_USERNAME"); username != "" { 69 | opts = append(opts, slack.MsgOptionUsername(username)) 70 | } 71 | 72 | if emoji := os.Getenv("SLACK_ICON_EMOJI"); emoji != "" { 73 | opts = append(opts, slack.MsgOptionIconEmoji(emoji)) 74 | } 75 | 76 | if url := os.Getenv("SLACK_ICON_URL"); url != "" { 77 | opts = append(opts, slack.MsgOptionIconURL(url)) 78 | } 79 | 80 | if _, _, err := c.client.PostMessageContext(ctx, channelID, opts...); err != nil { 81 | return err 82 | } 83 | return nil 84 | } 85 | 86 | func (c *Client) postWebbookMessage(ctx context.Context, m string) error { 87 | url := os.Getenv("SLACK_WEBHOOK_URL") 88 | msg := buildWebhookMessage(m) 89 | return slack.PostWebhookContext(ctx, url, msg) 90 | } 91 | 92 | func (c *Client) getChannelIDByName(ctx context.Context, channel string) (string, error) { 93 | channel = strings.TrimPrefix(channel, "#") 94 | if cc, ok := c.channelCache[channel]; ok { 95 | return cc.ID, nil 96 | } 97 | var ( 98 | nc string 99 | err error 100 | cID string 101 | ) 102 | L: 103 | for { 104 | var ch []slack.Channel 105 | p := &slack.GetConversationsParameters{ 106 | Limit: 1000, 107 | Cursor: nc, 108 | } 109 | ch, nc, err = c.client.GetConversationsContext(ctx, p) 110 | if err != nil { 111 | return "", err 112 | } 113 | for _, cc := range ch { 114 | c.channelCache[channel] = cc 115 | if cc.Name == channel { 116 | cID = cc.ID 117 | break L 118 | } 119 | } 120 | if nc == "" { 121 | break 122 | } 123 | } 124 | if cID == "" { 125 | return "", fmt.Errorf("not found channel: %s", channel) 126 | } 127 | 128 | return cID, nil 129 | } 130 | 131 | func (c *Client) GetMentionLinkByName(ctx context.Context, name string) (string, error) { 132 | if c.client == nil { 133 | c.client = slack.New(os.Getenv("SLACK_API_TOKEN")) 134 | defer func() { 135 | c.client = nil 136 | }() 137 | } 138 | name = strings.TrimPrefix(name, "@") 139 | switch name { 140 | case "channel", "here", "everyone": 141 | return fmt.Sprintf("", name), nil 142 | } 143 | if uc, ok := c.userCache[name]; ok { 144 | // https://api.slack.com/reference/surfaces/formatting#mentioning-users 145 | return fmt.Sprintf("<@%s>", uc.ID), nil 146 | } 147 | if gc, ok := c.userGroupCache[name]; ok { 148 | // https://api.slack.com/reference/surfaces/formatting#mentioning-groups 149 | return fmt.Sprintf("", gc.ID), nil 150 | } 151 | 152 | users, err := c.client.GetUsersContext(ctx) 153 | if err != nil { 154 | return "", err 155 | } 156 | 157 | for _, u := range users { 158 | c.userCache[u.Name] = u 159 | } 160 | uc, ok := c.userCache[name] 161 | if ok { 162 | return fmt.Sprintf("<@%s>", uc.ID), nil 163 | } 164 | 165 | groups, err := c.client.GetUserGroupsContext(ctx) 166 | if err != nil { 167 | return "", err 168 | } 169 | for _, g := range groups { 170 | c.userGroupCache[g.Handle] = g 171 | } 172 | gc, ok := c.userGroupCache[name] 173 | if ok { 174 | return fmt.Sprintf("", gc.ID), nil 175 | } 176 | 177 | return fmt.Sprintf("<@%s|not found user or usergroup>", name), nil 178 | } 179 | 180 | func buildWebhookMessage(m string) *slack.WebhookMessage { 181 | return &slack.WebhookMessage{ 182 | Channel: os.Getenv("SLACK_CHANNEL"), 183 | Blocks: &slack.Blocks{ 184 | BlockSet: buildBlocks(m), 185 | }, 186 | } 187 | } 188 | 189 | // buildBlocks 190 | func buildBlocks(m string) []slack.Block { 191 | elements := []slack.MixedElement{slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("%s | <%s|#%s> | %s", os.Getenv("GITHUB_REPOSITORY"), os.Getenv("GHDAG_TARGET_URL"), os.Getenv("GHDAG_TARGET_NUMBER"), os.Getenv("GHDAG_TASK_ID")), false, false)} 192 | contextBlock := slack.NewContextBlock("footer", elements...) 193 | return []slack.Block{ 194 | slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", m, false, false), nil, nil), 195 | contextBlock, 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /mock/mock_gh.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: gh/gh.go 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | target "github.com/k1LoW/ghdag/target" 13 | ) 14 | 15 | // MockGhClient is a mock of GhClient interface. 16 | type MockGhClient struct { 17 | ctrl *gomock.Controller 18 | recorder *MockGhClientMockRecorder 19 | } 20 | 21 | // MockGhClientMockRecorder is the mock recorder for MockGhClient. 22 | type MockGhClientMockRecorder struct { 23 | mock *MockGhClient 24 | } 25 | 26 | // NewMockGhClient creates a new mock instance. 27 | func NewMockGhClient(ctrl *gomock.Controller) *MockGhClient { 28 | mock := &MockGhClient{ctrl: ctrl} 29 | mock.recorder = &MockGhClientMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockGhClient) EXPECT() *MockGhClientMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // AddComment mocks base method. 39 | func (m *MockGhClient) AddComment(ctx context.Context, n int, comment string) error { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "AddComment", ctx, n, comment) 42 | ret0, _ := ret[0].(error) 43 | return ret0 44 | } 45 | 46 | // AddComment indicates an expected call of AddComment. 47 | func (mr *MockGhClientMockRecorder) AddComment(ctx, n, comment interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddComment", reflect.TypeOf((*MockGhClient)(nil).AddComment), ctx, n, comment) 50 | } 51 | 52 | // CloseIssue mocks base method. 53 | func (m *MockGhClient) CloseIssue(ctx context.Context, n int) error { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "CloseIssue", ctx, n) 56 | ret0, _ := ret[0].(error) 57 | return ret0 58 | } 59 | 60 | // CloseIssue indicates an expected call of CloseIssue. 61 | func (mr *MockGhClientMockRecorder) CloseIssue(ctx, n interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseIssue", reflect.TypeOf((*MockGhClient)(nil).CloseIssue), ctx, n) 64 | } 65 | 66 | // FetchTarget mocks base method. 67 | func (m *MockGhClient) FetchTarget(ctx context.Context, n int) (*target.Target, error) { 68 | m.ctrl.T.Helper() 69 | ret := m.ctrl.Call(m, "FetchTarget", ctx, n) 70 | ret0, _ := ret[0].(*target.Target) 71 | ret1, _ := ret[1].(error) 72 | return ret0, ret1 73 | } 74 | 75 | // FetchTarget indicates an expected call of FetchTarget. 76 | func (mr *MockGhClientMockRecorder) FetchTarget(ctx, n interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchTarget", reflect.TypeOf((*MockGhClient)(nil).FetchTarget), ctx, n) 79 | } 80 | 81 | // FetchTargets mocks base method. 82 | func (m *MockGhClient) FetchTargets(ctx context.Context) (target.Targets, error) { 83 | m.ctrl.T.Helper() 84 | ret := m.ctrl.Call(m, "FetchTargets", ctx) 85 | ret0, _ := ret[0].(target.Targets) 86 | ret1, _ := ret[1].(error) 87 | return ret0, ret1 88 | } 89 | 90 | // FetchTargets indicates an expected call of FetchTargets. 91 | func (mr *MockGhClientMockRecorder) FetchTargets(ctx interface{}) *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchTargets", reflect.TypeOf((*MockGhClient)(nil).FetchTargets), ctx) 94 | } 95 | 96 | // MergePullRequest mocks base method. 97 | func (m *MockGhClient) MergePullRequest(ctx context.Context, n int) error { 98 | m.ctrl.T.Helper() 99 | ret := m.ctrl.Call(m, "MergePullRequest", ctx, n) 100 | ret0, _ := ret[0].(error) 101 | return ret0 102 | } 103 | 104 | // MergePullRequest indicates an expected call of MergePullRequest. 105 | func (mr *MockGhClientMockRecorder) MergePullRequest(ctx, n interface{}) *gomock.Call { 106 | mr.mock.ctrl.T.Helper() 107 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergePullRequest", reflect.TypeOf((*MockGhClient)(nil).MergePullRequest), ctx, n) 108 | } 109 | 110 | // ResolveUsers mocks base method. 111 | func (m *MockGhClient) ResolveUsers(ctx context.Context, in []string) ([]string, error) { 112 | m.ctrl.T.Helper() 113 | ret := m.ctrl.Call(m, "ResolveUsers", ctx, in) 114 | ret0, _ := ret[0].([]string) 115 | ret1, _ := ret[1].(error) 116 | return ret0, ret1 117 | } 118 | 119 | // ResolveUsers indicates an expected call of ResolveUsers. 120 | func (mr *MockGhClientMockRecorder) ResolveUsers(ctx, in interface{}) *gomock.Call { 121 | mr.mock.ctrl.T.Helper() 122 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveUsers", reflect.TypeOf((*MockGhClient)(nil).ResolveUsers), ctx, in) 123 | } 124 | 125 | // SetAssignees mocks base method. 126 | func (m *MockGhClient) SetAssignees(ctx context.Context, n int, assignees []string) error { 127 | m.ctrl.T.Helper() 128 | ret := m.ctrl.Call(m, "SetAssignees", ctx, n, assignees) 129 | ret0, _ := ret[0].(error) 130 | return ret0 131 | } 132 | 133 | // SetAssignees indicates an expected call of SetAssignees. 134 | func (mr *MockGhClientMockRecorder) SetAssignees(ctx, n, assignees interface{}) *gomock.Call { 135 | mr.mock.ctrl.T.Helper() 136 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAssignees", reflect.TypeOf((*MockGhClient)(nil).SetAssignees), ctx, n, assignees) 137 | } 138 | 139 | // SetLabels mocks base method. 140 | func (m *MockGhClient) SetLabels(ctx context.Context, n int, labels []string) error { 141 | m.ctrl.T.Helper() 142 | ret := m.ctrl.Call(m, "SetLabels", ctx, n, labels) 143 | ret0, _ := ret[0].(error) 144 | return ret0 145 | } 146 | 147 | // SetLabels indicates an expected call of SetLabels. 148 | func (mr *MockGhClientMockRecorder) SetLabels(ctx, n, labels interface{}) *gomock.Call { 149 | mr.mock.ctrl.T.Helper() 150 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLabels", reflect.TypeOf((*MockGhClient)(nil).SetLabels), ctx, n, labels) 151 | } 152 | 153 | // SetReviewers mocks base method. 154 | func (m *MockGhClient) SetReviewers(ctx context.Context, n int, reviewers []string) error { 155 | m.ctrl.T.Helper() 156 | ret := m.ctrl.Call(m, "SetReviewers", ctx, n, reviewers) 157 | ret0, _ := ret[0].(error) 158 | return ret0 159 | } 160 | 161 | // SetReviewers indicates an expected call of SetReviewers. 162 | func (mr *MockGhClientMockRecorder) SetReviewers(ctx, n, reviewers interface{}) *gomock.Call { 163 | mr.mock.ctrl.T.Helper() 164 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReviewers", reflect.TypeOf((*MockGhClient)(nil).SetReviewers), ctx, n, reviewers) 165 | } 166 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Workflow Examples 2 | 3 | ## Randomly assign one member to the issue. 4 | 5 | ``` yaml 6 | # myworkflow.yml 7 | --- 8 | tasks: 9 | - 10 | id: assign-issue 11 | if: 'is_issue && len(assignees) == 0' 12 | do: 13 | assignees: [alice bob charlie] 14 | env: 15 | GITHUB_ASSIGNEES_SAMPLE: 1 16 | ``` 17 | 18 |
19 | 20 | GitHub Actions workflow YAML file 21 | 22 | ``` yaml 23 | # .github/workflows/ghdag_workflow.yml 24 | name: ghdag workflow 25 | on: 26 | issues: 27 | types: [opened] 28 | issue_comment: 29 | types: [created] 30 | 31 | jobs: 32 | run-workflow: 33 | name: 'Run workflow for A **single** `opened` issue that triggered the event' 34 | runs-on: ubuntu-latest 35 | container: ghcr.io/k1low/ghdag:latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v2 39 | with: 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | - name: Run ghdag 42 | run: ghdag run myworkflow.yml 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | ``` 46 | 47 |
48 | 49 | ## Assign a reviewer to a pull request with the 'needs review' label 50 | 51 | ``` yaml 52 | # myworkflow.yml 53 | --- 54 | tasks: 55 | - 56 | id: assign-pull-request 57 | if: 'is_pull_request && len(reviewers) == 0 && "needs review" in labels' 58 | do: 59 | reviewers: [alice bob charlie] 60 | env: 61 | GITHUB_REVIEWERS_SAMPLE: 1 62 | ``` 63 | 64 |
65 | 66 | GitHub Actions workflow YAML file 67 | 68 | ``` yaml 69 | # .github/workflows/ghdag_workflow.yml 70 | name: ghdag workflow 71 | on: 72 | pull_request: 73 | types: [labeled] 74 | 75 | jobs: 76 | run-workflow: 77 | name: 'Run workflow for A **single** `opened` issue that triggered the event' 78 | runs-on: ubuntu-latest 79 | container: ghcr.io/k1low/ghdag:latest 80 | steps: 81 | - name: Checkout 82 | uses: actions/checkout@v2 83 | with: 84 | token: ${{ secrets.GITHUB_TOKEN }} 85 | - name: Run ghdag 86 | run: ghdag run myworkflow.yml 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | ``` 90 | 91 |
92 | 93 | ## Close issues that have not been updated in 30 days 94 | 95 | ``` yaml 96 | # myworkflow.yml 97 | --- 98 | tasks: 99 | - 100 | id: close-issues-30days 101 | if: hours_elapsed_since_updated > (30 * 24) 102 | do: 103 | state: close 104 | ``` 105 | 106 |
107 | 108 | GitHub Actions workflow YAML file 109 | 110 | ``` yaml 111 | # .github/workflows/ghdag_workflow.yml 112 | name: ghdag workflow 113 | on: 114 | schedule: 115 | # Run at 00:05 every day. 116 | - cron: 5 0 * * * 117 | 118 | jobs: 119 | run-workflow: 120 | name: 'Run workflow for **All** `opened` and `not draft` issues and pull requests' 121 | runs-on: ubuntu-latest 122 | container: ghcr.io/k1low/ghdag:latest 123 | steps: 124 | - name: Checkout 125 | uses: actions/checkout@v2 126 | with: 127 | token: ${{ secrets.GITHUB_TOKEN }} 128 | - name: Run ghdag 129 | run: ghdag run myworkflow.yml 130 | env: 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | ``` 133 | 134 |
135 | 136 | ## Reminder for specific Issues 137 | 138 | ``` yaml 139 | # myworkflow.yml 140 | --- 141 | tasks: 142 | - 143 | id: remind-issue-5541 144 | if: 'number == 5541' 145 | do: 146 | notify: 'Issue #5541 is still open. Please try to resolve it.' 147 | env: 148 | SLACK_CHANNEL: operation 149 | SLACK_MENTIONS: [k1low] 150 | ``` 151 | 152 |
153 | 154 | GitHub Actions workflow YAML file 155 | 156 | ``` yaml 157 | # .github/workflows/ghdag_workflow.yml 158 | name: ghdag workflow 159 | on: 160 | schedule: 161 | # Run at 10:00 every Monday. 162 | - cron: 0 10 * * 1 163 | 164 | jobs: 165 | run-workflow: 166 | name: 'Run workflow for **All** `opened` and `not draft` issues and pull requests' 167 | runs-on: ubuntu-latest 168 | container: ghcr.io/k1low/ghdag:latest 169 | steps: 170 | - name: Checkout 171 | uses: actions/checkout@v2 172 | with: 173 | token: ${{ secrets.GITHUB_TOKEN }} 174 | - name: Run ghdag 175 | run: ghdag run myworkflow.yml 176 | env: 177 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 178 | SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} 179 | ``` 180 | 181 |
182 | 183 | ## Slack Notification with Mentions in sync with GitHub assignees 184 | 185 | ``` yaml 186 | # myworkflow.yml 187 | --- 188 | tasks: 189 | - 190 | id: assign-issue 191 | if: 'is_issue && len(assignees) == 0' 192 | do: 193 | assignees: [alice bob charlie] 194 | ok: 195 | next: [notify-assignees] 196 | env: 197 | GITHUB_ASSIGNEES_SAMPLE: 1 198 | - 199 | id: notify-assignees 200 | do: 201 | notify: You are assigned 202 | env: 203 | SLACK_CHANNEL: operation 204 | SLACK_ICON_EMOJI: white_check_mark 205 | SLACK_USERNAME: assign-bot 206 | SLACK_MENTIONS: alice_liddel bob_marly charlie_sheen 207 | SLACK_MENTIONS_SAMPLE: 1 208 | GHDAG_SAMPLE_WITH_SAME_SEED: true 209 | ``` 210 | 211 |
212 | 213 | GitHub Actions workflow YAML file 214 | 215 | ``` yaml 216 | # .github/workflows/ghdag_workflow.yml 217 | name: ghdag workflow 218 | on: 219 | issues: 220 | types: [opened] 221 | issue_comment: 222 | types: [created] 223 | 224 | jobs: 225 | run-workflow: 226 | name: 'Run workflow for A **single** `opened` issue that triggered the event' 227 | runs-on: ubuntu-latest 228 | container: ghcr.io/k1low/ghdag:latest 229 | steps: 230 | - name: Checkout 231 | uses: actions/checkout@v2 232 | with: 233 | token: ${{ secrets.GITHUB_TOKEN }} 234 | - name: Run ghdag 235 | run: ghdag run myworkflow.yml 236 | env: 237 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 238 | SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} 239 | ``` 240 | 241 |
242 | 243 | ## Allow auto-merge using label 244 | 245 | ``` yaml 246 | # myworkflow.yml 247 | --- 248 | tasks: 249 | - 250 | id: allow-auto-merge 251 | if: 'is_approved && mergeable && "allow-auto-merge" in labels' 252 | do: 253 | state: merge 254 | ``` 255 | 256 |
257 | 258 | GitHub Actions workflow YAML file 259 | 260 | ``` yaml 261 | # .github/workflows/ghdag_workflow.yml 262 | name: ghdag workflow 263 | on: 264 | pull_request_review: 265 | 266 | jobs: 267 | run-workflow: 268 | name: 'Run workflow for A **single** `opened` issue that triggered the event' 269 | runs-on: ubuntu-latest 270 | container: ghcr.io/k1low/ghdag:latest 271 | steps: 272 | - name: Checkout 273 | uses: actions/checkout@v2 274 | with: 275 | token: ${{ secrets.GITHUB_TOKEN }} 276 | - name: Run ghdag 277 | run: ghdag run myworkflow.yml 278 | env: 279 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 280 | ``` 281 | 282 |
283 | -------------------------------------------------------------------------------- /testdata/event_issue_opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "active_lock_reason": null, 5 | "assignee": null, 6 | "assignees": [], 7 | "author_association": "OWNER", 8 | "body": "test", 9 | "closed_at": null, 10 | "comments": 0, 11 | "comments_url": "https://api.github.com/repos/k1LoW/opr/issues/19/comments", 12 | "created_at": "2021-03-02T20:55:20Z", 13 | "events_url": "https://api.github.com/repos/k1LoW/opr/issues/19/events", 14 | "html_url": "https://github.com/k1LoW/opr/issues/19", 15 | "id": 820383605, 16 | "labels": [], 17 | "labels_url": "https://api.github.com/repos/k1LoW/opr/issues/19/labels{/name}", 18 | "locked": false, 19 | "milestone": null, 20 | "node_id": "MDU6SXNzdWU4MjAzODM2MDU=", 21 | "number": 19, 22 | "performed_via_github_app": null, 23 | "repository_url": "https://api.github.com/repos/k1LoW/opr", 24 | "state": "open", 25 | "title": "test issue title", 26 | "updated_at": "2021-03-02T20:55:20Z", 27 | "url": "https://api.github.com/repos/k1LoW/opr/issues/19", 28 | "user": { 29 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 30 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 31 | "followers_url": "https://api.github.com/users/k1LoW/followers", 32 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 33 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 34 | "gravatar_id": "", 35 | "html_url": "https://github.com/k1LoW", 36 | "id": 57114, 37 | "login": "k1LoW", 38 | "node_id": "MDQ6VXNlcjU3MTE0", 39 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 40 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 41 | "repos_url": "https://api.github.com/users/k1LoW/repos", 42 | "site_admin": false, 43 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 44 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 45 | "type": "User", 46 | "url": "https://api.github.com/users/k1LoW" 47 | } 48 | }, 49 | "repository": { 50 | "archive_url": "https://api.github.com/repos/k1LoW/opr/{archive_format}{/ref}", 51 | "archived": false, 52 | "assignees_url": "https://api.github.com/repos/k1LoW/opr/assignees{/user}", 53 | "blobs_url": "https://api.github.com/repos/k1LoW/opr/git/blobs{/sha}", 54 | "branches_url": "https://api.github.com/repos/k1LoW/opr/branches{/branch}", 55 | "clone_url": "https://github.com/k1LoW/opr.git", 56 | "collaborators_url": "https://api.github.com/repos/k1LoW/opr/collaborators{/collaborator}", 57 | "comments_url": "https://api.github.com/repos/k1LoW/opr/comments{/number}", 58 | "commits_url": "https://api.github.com/repos/k1LoW/opr/commits{/sha}", 59 | "compare_url": "https://api.github.com/repos/k1LoW/opr/compare/{base}...{head}", 60 | "contents_url": "https://api.github.com/repos/k1LoW/opr/contents/{+path}", 61 | "contributors_url": "https://api.github.com/repos/k1LoW/opr/contributors", 62 | "created_at": "2018-12-10T00:20:40Z", 63 | "default_branch": "master", 64 | "deployments_url": "https://api.github.com/repos/k1LoW/opr/deployments", 65 | "description": null, 66 | "disabled": false, 67 | "downloads_url": "https://api.github.com/repos/k1LoW/opr/downloads", 68 | "events_url": "https://api.github.com/repos/k1LoW/opr/events", 69 | "fork": false, 70 | "forks": 0, 71 | "forks_count": 0, 72 | "forks_url": "https://api.github.com/repos/k1LoW/opr/forks", 73 | "full_name": "k1LoW/opr", 74 | "git_commits_url": "https://api.github.com/repos/k1LoW/opr/git/commits{/sha}", 75 | "git_refs_url": "https://api.github.com/repos/k1LoW/opr/git/refs{/sha}", 76 | "git_tags_url": "https://api.github.com/repos/k1LoW/opr/git/tags{/sha}", 77 | "git_url": "git://github.com/k1LoW/opr.git", 78 | "has_downloads": true, 79 | "has_issues": true, 80 | "has_pages": false, 81 | "has_projects": true, 82 | "has_wiki": true, 83 | "homepage": null, 84 | "hooks_url": "https://api.github.com/repos/k1LoW/opr/hooks", 85 | "html_url": "https://github.com/k1LoW/opr", 86 | "id": 161094439, 87 | "issue_comment_url": "https://api.github.com/repos/k1LoW/opr/issues/comments{/number}", 88 | "issue_events_url": "https://api.github.com/repos/k1LoW/opr/issues/events{/number}", 89 | "issues_url": "https://api.github.com/repos/k1LoW/opr/issues{/number}", 90 | "keys_url": "https://api.github.com/repos/k1LoW/opr/keys{/key_id}", 91 | "labels_url": "https://api.github.com/repos/k1LoW/opr/labels{/name}", 92 | "language": "Go", 93 | "languages_url": "https://api.github.com/repos/k1LoW/opr/languages", 94 | "license": { 95 | "key": "mit", 96 | "name": "MIT License", 97 | "node_id": "MDc6TGljZW5zZTEz", 98 | "spdx_id": "MIT", 99 | "url": "https://api.github.com/licenses/mit" 100 | }, 101 | "merges_url": "https://api.github.com/repos/k1LoW/opr/merges", 102 | "milestones_url": "https://api.github.com/repos/k1LoW/opr/milestones{/number}", 103 | "mirror_url": null, 104 | "name": "opr", 105 | "node_id": "MDEwOlJlcG9zaXRvcnkxNjEwOTQ0Mzk=", 106 | "notifications_url": "https://api.github.com/repos/k1LoW/opr/notifications{?since,all,participating}", 107 | "open_issues": 5, 108 | "open_issues_count": 5, 109 | "owner": { 110 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 111 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 112 | "followers_url": "https://api.github.com/users/k1LoW/followers", 113 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 114 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 115 | "gravatar_id": "", 116 | "html_url": "https://github.com/k1LoW", 117 | "id": 57114, 118 | "login": "k1LoW", 119 | "node_id": "MDQ6VXNlcjU3MTE0", 120 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 121 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 122 | "repos_url": "https://api.github.com/users/k1LoW/repos", 123 | "site_admin": false, 124 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 125 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 126 | "type": "User", 127 | "url": "https://api.github.com/users/k1LoW" 128 | }, 129 | "private": true, 130 | "pulls_url": "https://api.github.com/repos/k1LoW/opr/pulls{/number}", 131 | "pushed_at": "2021-03-02T20:54:40Z", 132 | "releases_url": "https://api.github.com/repos/k1LoW/opr/releases{/id}", 133 | "size": 21, 134 | "ssh_url": "git@github.com:k1LoW/opr.git", 135 | "stargazers_count": 0, 136 | "stargazers_url": "https://api.github.com/repos/k1LoW/opr/stargazers", 137 | "statuses_url": "https://api.github.com/repos/k1LoW/opr/statuses/{sha}", 138 | "subscribers_url": "https://api.github.com/repos/k1LoW/opr/subscribers", 139 | "subscription_url": "https://api.github.com/repos/k1LoW/opr/subscription", 140 | "svn_url": "https://github.com/k1LoW/opr", 141 | "tags_url": "https://api.github.com/repos/k1LoW/opr/tags", 142 | "teams_url": "https://api.github.com/repos/k1LoW/opr/teams", 143 | "trees_url": "https://api.github.com/repos/k1LoW/opr/git/trees{/sha}", 144 | "updated_at": "2021-03-02T20:54:42Z", 145 | "url": "https://api.github.com/repos/k1LoW/opr", 146 | "watchers": 0, 147 | "watchers_count": 0 148 | }, 149 | "sender": { 150 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 151 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 152 | "followers_url": "https://api.github.com/users/k1LoW/followers", 153 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 154 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 155 | "gravatar_id": "", 156 | "html_url": "https://github.com/k1LoW", 157 | "id": 57114, 158 | "login": "k1LoW", 159 | "node_id": "MDQ6VXNlcjU3MTE0", 160 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 161 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 162 | "repos_url": "https://api.github.com/users/k1LoW/repos", 163 | "site_admin": false, 164 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 165 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 166 | "type": "User", 167 | "url": "https://api.github.com/users/k1LoW" 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /testdata/event_issue_comment_opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "comment": { 4 | "author_association": "OWNER", 5 | "body": "test", 6 | "created_at": "2021-03-02T21:13:01Z", 7 | "html_url": "https://github.com/k1LoW/opr/pull/20#issuecomment-789219581", 8 | "id": 789219581, 9 | "issue_url": "https://api.github.com/repos/k1LoW/opr/issues/20", 10 | "node_id": "MDEyOklzc3VlQ29tbWVudDc4OTIxOTU4MQ==", 11 | "performed_via_github_app": null, 12 | "updated_at": "2021-03-02T21:13:01Z", 13 | "url": "https://api.github.com/repos/k1LoW/opr/issues/comments/789219581", 14 | "user": { 15 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 16 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 17 | "followers_url": "https://api.github.com/users/k1LoW/followers", 18 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 19 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 20 | "gravatar_id": "", 21 | "html_url": "https://github.com/k1LoW", 22 | "id": 57114, 23 | "login": "k1LoW", 24 | "node_id": "MDQ6VXNlcjU3MTE0", 25 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 26 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 27 | "repos_url": "https://api.github.com/users/k1LoW/repos", 28 | "site_admin": false, 29 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 30 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 31 | "type": "User", 32 | "url": "https://api.github.com/users/k1LoW" 33 | } 34 | }, 35 | "issue": { 36 | "active_lock_reason": null, 37 | "assignee": null, 38 | "assignees": [], 39 | "author_association": "OWNER", 40 | "body": "", 41 | "closed_at": null, 42 | "comments": 1, 43 | "comments_url": "https://api.github.com/repos/k1LoW/opr/issues/20/comments", 44 | "created_at": "2021-03-02T21:01:47Z", 45 | "events_url": "https://api.github.com/repos/k1LoW/opr/issues/20/events", 46 | "html_url": "https://github.com/k1LoW/opr/pull/20", 47 | "id": 820387715, 48 | "labels": [], 49 | "labels_url": "https://api.github.com/repos/k1LoW/opr/issues/20/labels{/name}", 50 | "locked": false, 51 | "milestone": null, 52 | "node_id": "MDExOlB1bGxSZXF1ZXN0NTgzMzQwMTg5", 53 | "number": 20, 54 | "performed_via_github_app": null, 55 | "pull_request": { 56 | "diff_url": "https://github.com/k1LoW/opr/pull/20.diff", 57 | "html_url": "https://github.com/k1LoW/opr/pull/20", 58 | "patch_url": "https://github.com/k1LoW/opr/pull/20.patch", 59 | "url": "https://api.github.com/repos/k1LoW/opr/pulls/20" 60 | }, 61 | "repository_url": "https://api.github.com/repos/k1LoW/opr", 62 | "state": "open", 63 | "title": "Update R.md", 64 | "updated_at": "2021-03-02T21:13:01Z", 65 | "url": "https://api.github.com/repos/k1LoW/opr/issues/20", 66 | "user": { 67 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 68 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 69 | "followers_url": "https://api.github.com/users/k1LoW/followers", 70 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 71 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 72 | "gravatar_id": "", 73 | "html_url": "https://github.com/k1LoW", 74 | "id": 57114, 75 | "login": "k1LoW", 76 | "node_id": "MDQ6VXNlcjU3MTE0", 77 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 78 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 79 | "repos_url": "https://api.github.com/users/k1LoW/repos", 80 | "site_admin": false, 81 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 82 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 83 | "type": "User", 84 | "url": "https://api.github.com/users/k1LoW" 85 | } 86 | }, 87 | "repository": { 88 | "archive_url": "https://api.github.com/repos/k1LoW/opr/{archive_format}{/ref}", 89 | "archived": false, 90 | "assignees_url": "https://api.github.com/repos/k1LoW/opr/assignees{/user}", 91 | "blobs_url": "https://api.github.com/repos/k1LoW/opr/git/blobs{/sha}", 92 | "branches_url": "https://api.github.com/repos/k1LoW/opr/branches{/branch}", 93 | "clone_url": "https://github.com/k1LoW/opr.git", 94 | "collaborators_url": "https://api.github.com/repos/k1LoW/opr/collaborators{/collaborator}", 95 | "comments_url": "https://api.github.com/repos/k1LoW/opr/comments{/number}", 96 | "commits_url": "https://api.github.com/repos/k1LoW/opr/commits{/sha}", 97 | "compare_url": "https://api.github.com/repos/k1LoW/opr/compare/{base}...{head}", 98 | "contents_url": "https://api.github.com/repos/k1LoW/opr/contents/{+path}", 99 | "contributors_url": "https://api.github.com/repos/k1LoW/opr/contributors", 100 | "created_at": "2018-12-10T00:20:40Z", 101 | "default_branch": "master", 102 | "deployments_url": "https://api.github.com/repos/k1LoW/opr/deployments", 103 | "description": null, 104 | "disabled": false, 105 | "downloads_url": "https://api.github.com/repos/k1LoW/opr/downloads", 106 | "events_url": "https://api.github.com/repos/k1LoW/opr/events", 107 | "fork": false, 108 | "forks": 0, 109 | "forks_count": 0, 110 | "forks_url": "https://api.github.com/repos/k1LoW/opr/forks", 111 | "full_name": "k1LoW/opr", 112 | "git_commits_url": "https://api.github.com/repos/k1LoW/opr/git/commits{/sha}", 113 | "git_refs_url": "https://api.github.com/repos/k1LoW/opr/git/refs{/sha}", 114 | "git_tags_url": "https://api.github.com/repos/k1LoW/opr/git/tags{/sha}", 115 | "git_url": "git://github.com/k1LoW/opr.git", 116 | "has_downloads": true, 117 | "has_issues": true, 118 | "has_pages": false, 119 | "has_projects": true, 120 | "has_wiki": true, 121 | "homepage": null, 122 | "hooks_url": "https://api.github.com/repos/k1LoW/opr/hooks", 123 | "html_url": "https://github.com/k1LoW/opr", 124 | "id": 161094439, 125 | "issue_comment_url": "https://api.github.com/repos/k1LoW/opr/issues/comments{/number}", 126 | "issue_events_url": "https://api.github.com/repos/k1LoW/opr/issues/events{/number}", 127 | "issues_url": "https://api.github.com/repos/k1LoW/opr/issues{/number}", 128 | "keys_url": "https://api.github.com/repos/k1LoW/opr/keys{/key_id}", 129 | "labels_url": "https://api.github.com/repos/k1LoW/opr/labels{/name}", 130 | "language": "Go", 131 | "languages_url": "https://api.github.com/repos/k1LoW/opr/languages", 132 | "license": { 133 | "key": "mit", 134 | "name": "MIT License", 135 | "node_id": "MDc6TGljZW5zZTEz", 136 | "spdx_id": "MIT", 137 | "url": "https://api.github.com/licenses/mit" 138 | }, 139 | "merges_url": "https://api.github.com/repos/k1LoW/opr/merges", 140 | "milestones_url": "https://api.github.com/repos/k1LoW/opr/milestones{/number}", 141 | "mirror_url": null, 142 | "name": "opr", 143 | "node_id": "MDEwOlJlcG9zaXRvcnkxNjEwOTQ0Mzk=", 144 | "notifications_url": "https://api.github.com/repos/k1LoW/opr/notifications{?since,all,participating}", 145 | "open_issues": 6, 146 | "open_issues_count": 6, 147 | "owner": { 148 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 149 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 150 | "followers_url": "https://api.github.com/users/k1LoW/followers", 151 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 152 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 153 | "gravatar_id": "", 154 | "html_url": "https://github.com/k1LoW", 155 | "id": 57114, 156 | "login": "k1LoW", 157 | "node_id": "MDQ6VXNlcjU3MTE0", 158 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 159 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 160 | "repos_url": "https://api.github.com/users/k1LoW/repos", 161 | "site_admin": false, 162 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 163 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 164 | "type": "User", 165 | "url": "https://api.github.com/users/k1LoW" 166 | }, 167 | "private": true, 168 | "pulls_url": "https://api.github.com/repos/k1LoW/opr/pulls{/number}", 169 | "pushed_at": "2021-03-02T21:01:48Z", 170 | "releases_url": "https://api.github.com/repos/k1LoW/opr/releases{/id}", 171 | "size": 21, 172 | "ssh_url": "git@github.com:k1LoW/opr.git", 173 | "stargazers_count": 0, 174 | "stargazers_url": "https://api.github.com/repos/k1LoW/opr/stargazers", 175 | "statuses_url": "https://api.github.com/repos/k1LoW/opr/statuses/{sha}", 176 | "subscribers_url": "https://api.github.com/repos/k1LoW/opr/subscribers", 177 | "subscription_url": "https://api.github.com/repos/k1LoW/opr/subscription", 178 | "svn_url": "https://github.com/k1LoW/opr", 179 | "tags_url": "https://api.github.com/repos/k1LoW/opr/tags", 180 | "teams_url": "https://api.github.com/repos/k1LoW/opr/teams", 181 | "trees_url": "https://api.github.com/repos/k1LoW/opr/git/trees{/sha}", 182 | "updated_at": "2021-03-02T20:54:42Z", 183 | "url": "https://api.github.com/repos/k1LoW/opr", 184 | "watchers": 0, 185 | "watchers_count": 0 186 | }, 187 | "sender": { 188 | "avatar_url": "https://avatars.githubusercontent.com/u/57114?v=4", 189 | "events_url": "https://api.github.com/users/k1LoW/events{/privacy}", 190 | "followers_url": "https://api.github.com/users/k1LoW/followers", 191 | "following_url": "https://api.github.com/users/k1LoW/following{/other_user}", 192 | "gists_url": "https://api.github.com/users/k1LoW/gists{/gist_id}", 193 | "gravatar_id": "", 194 | "html_url": "https://github.com/k1LoW", 195 | "id": 57114, 196 | "login": "k1LoW", 197 | "node_id": "MDQ6VXNlcjU3MTE0", 198 | "organizations_url": "https://api.github.com/users/k1LoW/orgs", 199 | "received_events_url": "https://api.github.com/users/k1LoW/received_events", 200 | "repos_url": "https://api.github.com/users/k1LoW/repos", 201 | "site_admin": false, 202 | "starred_url": "https://api.github.com/users/k1LoW/starred{/owner}{/repo}", 203 | "subscriptions_url": "https://api.github.com/users/k1LoW/subscriptions", 204 | "type": "User", 205 | "url": "https://api.github.com/users/k1LoW" 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v0.16.1](https://github.com/k1LoW/ghdag/compare/v0.16.0...v0.16.1) (2022-02-09) 2 | 3 | * Support darwin arm64 [#73](https://github.com/k1LoW/ghdag/pull/73) ([k1LoW](https://github.com/k1LoW)) 4 | 5 | ## [v0.16.0](https://github.com/k1LoW/ghdag/compare/v0.15.2...v0.16.0) (2021-07-16) 6 | 7 | * Support retry in `run:` action [#71](https://github.com/k1LoW/ghdag/pull/71) ([k1LoW](https://github.com/k1LoW)) 8 | * mv runner.DecodeGitHubEvent to gh.DecodeGitHubEvent [#70](https://github.com/k1LoW/ghdag/pull/70) ([k1LoW](https://github.com/k1LoW)) 9 | * Add log for scopes [#69](https://github.com/k1LoW/ghdag/pull/69) ([k1LoW](https://github.com/k1LoW)) 10 | 11 | ## [v0.15.2](https://github.com/k1LoW/ghdag/compare/v0.15.1...v0.15.2) (2021-05-29) 12 | 13 | * Add assert to runner.DecodeGitHubEvent() [#68](https://github.com/k1LoW/ghdag/pull/68) ([k1LoW](https://github.com/k1LoW)) 14 | 15 | ## [v0.15.1](https://github.com/k1LoW/ghdag/compare/v0.15.0...v0.15.1) (2021-05-10) 16 | 17 | 18 | ## [v0.15.0](https://github.com/k1LoW/ghdag/compare/v0.14.0...v0.15.0) (2021-05-10) 19 | 20 | * Use ghdag-action [#67](https://github.com/k1LoW/ghdag/pull/67) ([k1LoW](https://github.com/k1LoW)) 21 | * [BREAKING]Change base image ( alpine -> debian ) [#66](https://github.com/k1LoW/ghdag/pull/66) ([k1LoW](https://github.com/k1LoW)) 22 | * Create ghdag config dir when it does not exist [#65](https://github.com/k1LoW/ghdag/pull/65) ([k1LoW](https://github.com/k1LoW)) 23 | 24 | ## [v0.14.0](https://github.com/k1LoW/ghdag/compare/v0.13.1...v0.14.0) (2021-03-23) 25 | 26 | * Add variables `code_owners` `code_owners_who_approved` [#64](https://github.com/k1LoW/ghdag/pull/64) ([k1LoW](https://github.com/k1LoW)) 27 | * Add `GHDAG_ACTION_ASSIGNEES_BEHAVIOR` to set behavior of the `assignees:` action [#63](https://github.com/k1LoW/ghdag/pull/63) ([k1LoW](https://github.com/k1LoW)) 28 | 29 | ## [v0.13.1](https://github.com/k1LoW/ghdag/compare/v0.13.0...v0.13.1) (2021-03-22) 30 | 31 | * Remove variables `code_owners` `code_owners_who_approved` [#62](https://github.com/k1LoW/ghdag/pull/62) ([k1LoW](https://github.com/k1LoW)) 32 | * Fix reviewers count [#61](https://github.com/k1LoW/ghdag/pull/61) ([k1LoW](https://github.com/k1LoW)) 33 | 34 | ## [v0.13.0](https://github.com/k1LoW/ghdag/compare/v0.12.1...v0.13.0) (2021-03-19) 35 | 36 | * Dump `if:` section variables instead of github.event JSON [#60](https://github.com/k1LoW/ghdag/pull/60) ([k1LoW](https://github.com/k1LoW)) 37 | * Add `check` command [#59](https://github.com/k1LoW/ghdag/pull/59) ([k1LoW](https://github.com/k1LoW)) 38 | 39 | ## [v0.12.1](https://github.com/k1LoW/ghdag/compare/v0.12.0...v0.12.1) (2021-03-19) 40 | 41 | * Add variable `login` [#58](https://github.com/k1LoW/ghdag/pull/58) ([k1LoW](https://github.com/k1LoW)) 42 | * Add variables `env.*` [#57](https://github.com/k1LoW/ghdag/pull/57) ([k1LoW](https://github.com/k1LoW)) 43 | 44 | ## [v0.12.0](https://github.com/k1LoW/ghdag/compare/v0.11.1...v0.12.0) (2021-03-17) 45 | 46 | * Add `linkedNames:` for linking the GitHub user or team name to the Slack user or team account name. [#56](https://github.com/k1LoW/ghdag/pull/56) ([k1LoW](https://github.com/k1LoW)) 47 | 48 | ## [v0.11.1](https://github.com/k1LoW/ghdag/compare/v0.11.0...v0.11.1) (2021-03-17) 49 | 50 | * Fix reviewers: action [#55](https://github.com/k1LoW/ghdag/pull/55) ([k1LoW](https://github.com/k1LoW)) 51 | 52 | ## [v0.11.0](https://github.com/k1LoW/ghdag/compare/v0.10.3...v0.11.0) (2021-03-17) 53 | 54 | * [BREAKING] Remove variables `github_event_name` `github_event_action` [#54](https://github.com/k1LoW/ghdag/pull/54) ([k1LoW](https://github.com/k1LoW)) 55 | * Add variables `github.event_name` `github.event.*`, similar to GitHub Actions [#53](https://github.com/k1LoW/ghdag/pull/53) ([k1LoW](https://github.com/k1LoW)) 56 | * Remove debug print [#52](https://github.com/k1LoW/ghdag/pull/52) ([k1LoW](https://github.com/k1LoW)) 57 | * Fix GitHub event handling [#51](https://github.com/k1LoW/ghdag/pull/51) ([k1LoW](https://github.com/k1LoW)) 58 | 59 | ## [v0.10.3](https://github.com/k1LoW/ghdag/compare/v0.10.2...v0.10.3) (2021-03-16) 60 | 61 | * Fix env set bug [#50](https://github.com/k1LoW/ghdag/pull/50) ([k1LoW](https://github.com/k1LoW)) 62 | 63 | ## [v0.10.2](https://github.com/k1LoW/ghdag/compare/v0.10.1...v0.10.2) (2021-03-16) 64 | 65 | * Add variable `is_called` [#49](https://github.com/k1LoW/ghdag/pull/49) ([k1LoW](https://github.com/k1LoW)) 66 | 67 | ## [v0.10.1](https://github.com/k1LoW/ghdag/compare/v0.10.0...v0.10.1) (2021-03-12) 68 | 69 | * [BREAKING]Stop adding signatures ( `` ) to comments [#48](https://github.com/k1LoW/ghdag/pull/48) ([k1LoW](https://github.com/k1LoW)) 70 | 71 | ## [v0.10.0](https://github.com/k1LoW/ghdag/compare/v0.9.0...v0.10.0) (2021-03-12) 72 | 73 | * [BREAKING] Output log to STDERR [#47](https://github.com/k1LoW/ghdag/pull/47) ([k1LoW](https://github.com/k1LoW)) 74 | * Add `if` command [#46](https://github.com/k1LoW/ghdag/pull/46) ([k1LoW](https://github.com/k1LoW)) 75 | 76 | ## [v0.9.0](https://github.com/k1LoW/ghdag/compare/v0.8.0...v0.9.0) (2021-03-11) 77 | 78 | * Add variable `github_event_action` [#45](https://github.com/k1LoW/ghdag/pull/45) ([k1LoW](https://github.com/k1LoW)) 79 | * Add `GHDAG_ACTION_LABELS_BEHAVIOR` to set behavior of the `labels:` action [#44](https://github.com/k1LoW/ghdag/pull/44) ([k1LoW](https://github.com/k1LoW)) 80 | 81 | ## [v0.8.0](https://github.com/k1LoW/ghdag/compare/v0.7.1...v0.8.0) (2021-03-11) 82 | 83 | * Add `do` command to perform ghdag action as oneshot command [#40](https://github.com/k1LoW/ghdag/pull/40) ([k1LoW](https://github.com/k1LoW)) 84 | 85 | ## [v0.7.1](https://github.com/k1LoW/ghdag/compare/v0.7.0...v0.7.1) (2021-03-10) 86 | 87 | * Fix: invalid memory address or nil pointer dereference when using temporary SLACK_API_TOKEN [#43](https://github.com/k1LoW/ghdag/pull/43) ([k1LoW](https://github.com/k1LoW)) 88 | 89 | ## [v0.7.0](https://github.com/k1LoW/ghdag/compare/v0.6.0...v0.7.0) (2021-03-10) 90 | 91 | * Add variables `reviewers_who_approved` `code_owners_who_approved` [#42](https://github.com/k1LoW/ghdag/pull/42) ([k1LoW](https://github.com/k1LoW)) 92 | * Set caller result variables to `if:` section [#41](https://github.com/k1LoW/ghdag/pull/41) ([k1LoW](https://github.com/k1LoW)) 93 | * Use os.ExpandEnv [#39](https://github.com/k1LoW/ghdag/pull/39) ([k1LoW](https://github.com/k1LoW)) 94 | * Propagate action result environment variables to next tasks [#38](https://github.com/k1LoW/ghdag/pull/38) ([k1LoW](https://github.com/k1LoW)) 95 | 96 | ## [v0.6.0](https://github.com/k1LoW/ghdag/compare/v0.5.0...v0.6.0) (2021-03-09) 97 | 98 | * Propagating seed and excludeKey between caller and callee [#37](https://github.com/k1LoW/ghdag/pull/37) ([k1LoW](https://github.com/k1LoW)) 99 | * Set result of action environment variables [#36](https://github.com/k1LoW/ghdag/pull/36) ([k1LoW](https://github.com/k1LoW)) 100 | * Assign assignees/reviewers from environment variables [#35](https://github.com/k1LoW/ghdag/pull/35) ([k1LoW](https://github.com/k1LoW)) 101 | 102 | ## [v0.5.0](https://github.com/k1LoW/ghdag/compare/v0.4.0...v0.5.0) (2021-03-08) 103 | 104 | * Refactor runner.Runner [#34](https://github.com/k1LoW/ghdag/pull/34) ([k1LoW](https://github.com/k1LoW)) 105 | * Fix: raise EOF error when TARGET_ENV="" [#33](https://github.com/k1LoW/ghdag/pull/33) ([k1LoW](https://github.com/k1LoW)) 106 | * Add `GITHUB_COMMENT_MENTIONS` to add mentions to comment. [#32](https://github.com/k1LoW/ghdag/pull/32) ([k1LoW](https://github.com/k1LoW)) 107 | * Exclude author from reviewers [#31](https://github.com/k1LoW/ghdag/pull/31) ([k1LoW](https://github.com/k1LoW)) 108 | * Add env.ToSlice() [#30](https://github.com/k1LoW/ghdag/pull/30) ([k1LoW](https://github.com/k1LoW)) 109 | 110 | ## [v0.4.0](https://github.com/k1LoW/ghdag/compare/v0.3.0...v0.4.0) (2021-03-05) 111 | 112 | * Update Dockerfile [#29](https://github.com/k1LoW/ghdag/pull/29) ([k1LoW](https://github.com/k1LoW)) 113 | * Support custom usernme, icon_emoji, icon_url [#28](https://github.com/k1LoW/ghdag/pull/28) ([k1LoW](https://github.com/k1LoW)) 114 | * Set environment variable `GHDAG_CALLER_TASK_ID` [#27](https://github.com/k1LoW/ghdag/pull/27) ([k1LoW](https://github.com/k1LoW)) 115 | * Ensure that `GHDAG_SAMPLE_WITH_SAME_SEED` is also effective between caller task and callee task [#26](https://github.com/k1LoW/ghdag/pull/26) ([k1LoW](https://github.com/k1LoW)) 116 | * Fix GHDAG_ACTION_OK_ERROR -> GHDAG_ACTION_DO_ERROR [#25](https://github.com/k1LoW/ghdag/pull/25) ([k1LoW](https://github.com/k1LoW)) 117 | * Set STDOUT/STDERR of run action to environment variables [#24](https://github.com/k1LoW/ghdag/pull/24) ([k1LoW](https://github.com/k1LoW)) 118 | 119 | ## [v0.3.0](https://github.com/k1LoW/ghdag/compare/v0.2.3...v0.3.0) (2021-03-04) 120 | 121 | * Add an environment variable `GHDAG_SAMPLE_WITH_SAME_SEED` for sampling using the same seed as in the previous task. [#23](https://github.com/k1LoW/ghdag/pull/23) ([k1LoW](https://github.com/k1LoW)) 122 | * Generate a workflow YAML file for GitHub Actions, when `ghdag init` is executed [#22](https://github.com/k1LoW/ghdag/pull/22) ([k1LoW](https://github.com/k1LoW)) 123 | * Add ghdag workflow [#21](https://github.com/k1LoW/ghdag/pull/21) ([k1LoW](https://github.com/k1LoW)) 124 | 125 | ## [v0.2.3](https://github.com/k1LoW/ghdag/compare/v0.2.2...v0.2.3) (2021-03-03) 126 | 127 | * Increase task queue size [#20](https://github.com/k1LoW/ghdag/pull/20) ([k1LoW](https://github.com/k1LoW)) 128 | 129 | ## [v0.2.2](https://github.com/k1LoW/ghdag/compare/v0.2.1...v0.2.2) (2021-03-03) 130 | 131 | * Fix sampleByEnv [#19](https://github.com/k1LoW/ghdag/pull/19) ([k1LoW](https://github.com/k1LoW)) 132 | 133 | ## [v0.2.1](https://github.com/k1LoW/ghdag/compare/v0.2.0...v0.2.1) (2021-03-03) 134 | 135 | * Fix mention [#18](https://github.com/k1LoW/ghdag/pull/18) ([k1LoW](https://github.com/k1LoW)) 136 | 137 | ## [v0.2.0](https://github.com/k1LoW/ghdag/compare/v0.1.1...v0.2.0) (2021-03-03) 138 | 139 | * Detect issue or pull request number using GITHUB_EVENT_PATH [#17](https://github.com/k1LoW/ghdag/pull/17) ([k1LoW](https://github.com/k1LoW)) 140 | 141 | ## [v0.1.1](https://github.com/k1LoW/ghdag/compare/v0.1.0...v0.1.1) (2021-03-02) 142 | 143 | * Fix GHDAG_TARGET_* environment variables are not set [#16](https://github.com/k1LoW/ghdag/pull/16) ([k1LoW](https://github.com/k1LoW)) 144 | * Fix sampling [#15](https://github.com/k1LoW/ghdag/pull/15) ([k1LoW](https://github.com/k1LoW)) 145 | 146 | ## [v0.1.0](https://github.com/k1LoW/ghdag/compare/f4ae05b30c05...v0.1.0) (2021-03-02) 147 | 148 | * Add variable `code_owners` [#14](https://github.com/k1LoW/ghdag/pull/14) ([k1LoW](https://github.com/k1LoW)) 149 | * Set environment variables for target variables [#13](https://github.com/k1LoW/ghdag/pull/13) ([k1LoW](https://github.com/k1LoW)) 150 | * Add circuit breaker for comments [#12](https://github.com/k1LoW/ghdag/pull/12) ([k1LoW](https://github.com/k1LoW)) 151 | * Skip action if the target is already in a state of being wanted [#11](https://github.com/k1LoW/ghdag/pull/11) ([k1LoW](https://github.com/k1LoW)) 152 | * Parse comment/notify with environment variables [#10](https://github.com/k1LoW/ghdag/pull/10) ([k1LoW](https://github.com/k1LoW)) 153 | * Add variable `mergeable` [#9](https://github.com/k1LoW/ghdag/pull/9) ([k1LoW](https://github.com/k1LoW)) 154 | * Sampling mentions using SLACK_MENTIONS_SAMPLE [#8](https://github.com/k1LoW/ghdag/pull/8) ([k1LoW](https://github.com/k1LoW)) 155 | * Support Slack mentions [#7](https://github.com/k1LoW/ghdag/pull/7) ([k1LoW](https://github.com/k1LoW)) 156 | * Add action `reviewers:` [#6](https://github.com/k1LoW/ghdag/pull/6) ([k1LoW](https://github.com/k1LoW)) 157 | * Add variables `reviewers` [#5](https://github.com/k1LoW/ghdag/pull/5) ([k1LoW](https://github.com/k1LoW)) 158 | * Use GitHub GraphQL API [#4](https://github.com/k1LoW/ghdag/pull/4) ([k1LoW](https://github.com/k1LoW)) 159 | * Add environment variables expansion [#3](https://github.com/k1LoW/ghdag/pull/3) ([k1LoW](https://github.com/k1LoW)) 160 | * Sampling assignees using GITHUB_ASSIGNEES_SAMPLE [#2](https://github.com/k1LoW/ghdag/pull/2) ([k1LoW](https://github.com/k1LoW)) 161 | * Add zerolog [#1](https://github.com/k1LoW/ghdag/pull/1) ([k1LoW](https://github.com/k1LoW)) 162 | -------------------------------------------------------------------------------- /runner/actions.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/google/go-cmp/cmp" 15 | "github.com/k1LoW/duration" 16 | "github.com/k1LoW/exec" 17 | "github.com/k1LoW/ghdag/env" 18 | "github.com/k1LoW/ghdag/erro" 19 | "github.com/k1LoW/ghdag/target" 20 | "github.com/k1LoW/ghdag/task" 21 | "github.com/lestrrat-go/backoff/v2" 22 | ) 23 | 24 | func (r *Runner) PerformRunAction(ctx context.Context, _ *target.Target, command string) error { 25 | r.log(fmt.Sprintf("Run command: %s", command)) 26 | max := 0 27 | timeout := 300 * time.Second 28 | p := backoff.Null() 29 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_MAX") != "" || os.Getenv("GHDAG_ACTION_RUN_RETRY_TIMEOUT") != "" { 30 | mini := 0 * time.Second 31 | maxi := 0 * time.Second 32 | jf := 0.05 33 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_MAX") != "" { 34 | i, err := strconv.Atoi(os.Getenv("GHDAG_ACTION_RUN_RETRY_MAX")) 35 | if err != nil { 36 | return err 37 | } 38 | max = i 39 | } 40 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_TIMEOUT") != "" { 41 | t, err := duration.Parse(os.Getenv("GHDAG_ACTION_RUN_RETRY_TIMEOUT")) 42 | if err != nil { 43 | return err 44 | } 45 | timeout = t 46 | } 47 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_MIN_INTERVAL") != "" { 48 | t, err := duration.Parse(os.Getenv("GHDAG_ACTION_RUN_RETRY_MIN_INTERVAL")) 49 | if err != nil { 50 | return err 51 | } 52 | mini = t 53 | } 54 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_MAX_INTERVAL") != "" { 55 | t, err := duration.Parse(os.Getenv("GHDAG_ACTION_RUN_RETRY_MAX_INTERVAL")) 56 | if err != nil { 57 | return err 58 | } 59 | maxi = t 60 | } 61 | if os.Getenv("GHDAG_ACTION_RUN_RETRY_JITTER_FACTOR") != "" { 62 | f, err := strconv.ParseFloat(os.Getenv("GHDAG_ACTION_RUN_RETRY_JITTER_FACTOR"), 64) 63 | if err != nil { 64 | return err 65 | } 66 | jf = f 67 | } 68 | p = backoff.Exponential( 69 | backoff.WithMinInterval(mini), 70 | backoff.WithMaxInterval(maxi), 71 | backoff.WithJitterFactor(jf), 72 | ) 73 | } 74 | ctx2, cancel := context.WithTimeout(ctx, timeout) 75 | defer cancel() 76 | 77 | c := p.Start(ctx2) 78 | count := 0 79 | var err error 80 | for backoff.Continue(c) { 81 | c := exec.CommandContext(ctx2, "sh", "-c", command) 82 | c.Env = os.Environ() 83 | outbuf := new(bytes.Buffer) 84 | outmw := io.MultiWriter(os.Stdout, outbuf) 85 | c.Stdout = outmw 86 | errbuf := new(bytes.Buffer) 87 | errmw := io.MultiWriter(os.Stderr, errbuf) 88 | c.Stderr = errmw 89 | err = c.Run() 90 | count += 1 91 | if err := os.Setenv("GHDAG_ACTION_RUN_STDOUT", outbuf.String()); err != nil { 92 | return err 93 | } 94 | if err := os.Setenv("GHDAG_ACTION_RUN_STDERR", errbuf.String()); err != nil { 95 | return err 96 | } 97 | if err != nil { 98 | if count > max { 99 | if max > 0 { 100 | r.log(fmt.Sprintf("Exceeded max retry count: %d", max)) 101 | } 102 | break 103 | } 104 | continue 105 | } 106 | return nil 107 | } 108 | return err 109 | } 110 | 111 | func (r *Runner) PerformLabelsAction(ctx context.Context, i *target.Target, labels []string) error { 112 | b := os.Getenv("GHDAG_ACTION_LABELS_BEHAVIOR") 113 | switch b { 114 | case "add": 115 | r.log(fmt.Sprintf("Add labels: %s", strings.Join(labels, ", "))) 116 | labels = unique(append(labels, i.Labels...)) 117 | case "remove": 118 | r.log(fmt.Sprintf("Remove labels: %s", strings.Join(labels, ", "))) 119 | removed := []string{} 120 | for _, l := range i.Labels { 121 | if contains(labels, l) { 122 | continue 123 | } 124 | removed = append(removed, l) 125 | } 126 | labels = removed 127 | case "replace", "": 128 | r.log(fmt.Sprintf("Replace labels: %s", strings.Join(labels, ", "))) 129 | default: 130 | return fmt.Errorf("invalid behavior: %s", b) 131 | } 132 | 133 | sortStringSlice(i.Labels) 134 | sortStringSlice(labels) 135 | if cmp.Equal(i.Labels, labels) { 136 | if err := os.Setenv("GHDAG_ACTION_LABELS_UPDATED", env.Join(labels)); err != nil { 137 | return err 138 | } 139 | return erro.NewAlreadyInStateError(fmt.Errorf("the target is already in a state of being wanted: %s", strings.Join(labels, ", "))) 140 | } 141 | if err := r.github.SetLabels(ctx, i.Number, labels); err != nil { 142 | return err 143 | } 144 | if err := os.Setenv("GHDAG_ACTION_LABELS_UPDATED", env.Join(labels)); err != nil { 145 | return err 146 | } 147 | return nil 148 | } 149 | 150 | func (r *Runner) PerformAssigneesAction(ctx context.Context, i *target.Target, assignees []string) error { 151 | assignees = r.config.LinkedNames.ToGithubNames(assignees) 152 | assignees, err := r.github.ResolveUsers(ctx, assignees) 153 | if err != nil { 154 | return err 155 | } 156 | assignees, err = r.sample(assignees, "GITHUB_ASSIGNEES_SAMPLE") 157 | if err != nil { 158 | return err 159 | } 160 | b := os.Getenv("GHDAG_ACTION_ASSIGNEES_BEHAVIOR") 161 | switch b { 162 | case "add": 163 | r.log(fmt.Sprintf("Add assignees: %s", strings.Join(assignees, ", "))) 164 | assignees = unique(append(assignees, i.Assignees...)) 165 | case "remove": 166 | r.log(fmt.Sprintf("Remove assignees: %s", strings.Join(assignees, ", "))) 167 | removed := []string{} 168 | for _, l := range i.Assignees { 169 | if contains(assignees, l) { 170 | continue 171 | } 172 | removed = append(removed, l) 173 | } 174 | assignees = removed 175 | case "replace", "": 176 | r.log(fmt.Sprintf("Replace assignees: %s", strings.Join(assignees, ", "))) 177 | default: 178 | return fmt.Errorf("invalid behavior: %s", b) 179 | } 180 | 181 | sortStringSlice(i.Assignees) 182 | sortStringSlice(assignees) 183 | if cmp.Equal(i.Assignees, assignees) { 184 | if err := os.Setenv("GHDAG_ACTION_ASSIGNEES_UPDATED", env.Join(assignees)); err != nil { 185 | return err 186 | } 187 | return erro.NewAlreadyInStateError(fmt.Errorf("the target is already in a state of being wanted: %s", strings.Join(assignees, ", "))) 188 | } 189 | if err := r.github.SetAssignees(ctx, i.Number, assignees); err != nil { 190 | return err 191 | } 192 | if err := os.Setenv("GHDAG_ACTION_ASSIGNEES_UPDATED", env.Join(assignees)); err != nil { 193 | return err 194 | } 195 | return nil 196 | } 197 | 198 | func (r *Runner) PerformReviewersAction(ctx context.Context, i *target.Target, reviewers []string) error { 199 | reviewers = r.config.LinkedNames.ToGithubNames(reviewers) 200 | if contains(reviewers, i.Author) { 201 | r.debuglog(fmt.Sprintf("Exclude author from reviewers: %s", reviewers)) 202 | if err := r.setExcludeKey(reviewers, i.Author); err != nil { 203 | return err 204 | } 205 | } 206 | reviewers, err := r.sample(reviewers, "GITHUB_REVIEWERS_SAMPLE") 207 | if err != nil { 208 | return err 209 | } 210 | if len(reviewers) == 0 { 211 | return erro.NewNoReviewerError(errors.New("no reviewers to assign")) 212 | } 213 | 214 | r.log(fmt.Sprintf("Set reviewers: %s", strings.Join(reviewers, ", "))) 215 | 216 | rb := i.NoCodeOwnerReviewers() 217 | sortStringSlice(rb) 218 | 219 | ra := []string{} 220 | for _, r := range reviewers { 221 | if contains(i.CodeOwners, r) { 222 | continue 223 | } 224 | ra = append(ra, r) 225 | } 226 | sortStringSlice(ra) 227 | 228 | if len(ra) == 0 || cmp.Equal(rb, ra) { 229 | if err := os.Setenv("GHDAG_ACTION_REVIEWERS_UPDATED", env.Join(ra)); err != nil { 230 | return err 231 | } 232 | return erro.NewAlreadyInStateError(fmt.Errorf("the target is already in a state of being wanted: %s", strings.Join(reviewers, ", "))) 233 | } 234 | if err := r.github.SetReviewers(ctx, i.Number, ra); err != nil { 235 | return err 236 | } 237 | if err := os.Setenv("GHDAG_ACTION_REVIEWERS_UPDATED", env.Join(ra)); err != nil { 238 | return err 239 | } 240 | return nil 241 | } 242 | 243 | func (r *Runner) PerformCommentAction(ctx context.Context, i *target.Target, comment string) error { 244 | c := os.ExpandEnv(comment) 245 | mentions, err := env.Split(os.Getenv("GITHUB_COMMENT_MENTIONS")) 246 | if err != nil { 247 | return err 248 | } 249 | mentions, err = r.sample(mentions, "GITHUB_COMMENT_MENTIONS_SAMPLE") 250 | if err != nil { 251 | return err 252 | } 253 | r.log(fmt.Sprintf("Add comment: %s", c)) 254 | 255 | max, err := strconv.Atoi(os.Getenv("GHDAG_ACTION_COMMENT_MAX")) 256 | if err != nil { 257 | max = 5 258 | } 259 | 260 | if i.NumberOfConsecutiveComments >= max { 261 | return fmt.Errorf("Too many comments in a row by same login: %d", i.NumberOfConsecutiveComments) 262 | } 263 | 264 | if i.LatestCommentBody == c { 265 | return erro.NewAlreadyInStateError(fmt.Errorf("the target is already in a state of being wanted: %s", c)) 266 | } 267 | 268 | fm := []string{} 269 | for _, m := range mentions { 270 | if !strings.HasPrefix(m, "@") { 271 | m = fmt.Sprintf("@%s", m) 272 | } 273 | fm = append(fm, m) 274 | } 275 | if len(fm) > 0 { 276 | c = fmt.Sprintf("%s %s", strings.Join(fm, " "), c) 277 | } 278 | if err := r.github.AddComment(ctx, i.Number, c); err != nil { 279 | return err 280 | } 281 | if err := os.Setenv("GHDAG_ACTION_COMMENT_CREATED", c); err != nil { 282 | return err 283 | } 284 | return nil 285 | } 286 | 287 | func (r *Runner) PerformStateAction(ctx context.Context, i *target.Target, state string) error { 288 | r.log(fmt.Sprintf("Change state: %s", state)) 289 | switch state { 290 | case "close", "closed": 291 | if err := r.github.CloseIssue(ctx, i.Number); err != nil { 292 | return err 293 | } 294 | state = "closed" 295 | case "merge", "merged": 296 | if err := r.github.MergePullRequest(ctx, i.Number); err != nil { 297 | return err 298 | } 299 | state = "merged" 300 | default: 301 | return fmt.Errorf("invalid state: %s", state) 302 | } 303 | if err := os.Setenv("GHDAG_ACTION_STATE_CHANGED", state); err != nil { 304 | return err 305 | } 306 | return nil 307 | } 308 | 309 | func (r *Runner) PerformNotifyAction(ctx context.Context, _ *target.Target, notify string) error { 310 | n := os.ExpandEnv(notify) 311 | mentions, err := env.Split(os.Getenv("SLACK_MENTIONS")) 312 | if err != nil { 313 | return err 314 | } 315 | mentions = r.config.LinkedNames.ToSlackNames(mentions) 316 | mentions, err = r.sample(mentions, "SLACK_MENTIONS_SAMPLE") 317 | if err != nil { 318 | return err 319 | } 320 | r.log(fmt.Sprintf("Send notification: %s", n)) 321 | if os.Getenv("SLACK_WEBHOOK_URL") != "" && len(mentions) > 0 { 322 | return errors.New("notification using webhook does not support mentions") 323 | } 324 | links := []string{} 325 | for _, m := range mentions { 326 | l, err := r.slack.GetMentionLinkByName(ctx, m) 327 | if err != nil { 328 | return err 329 | } 330 | links = append(links, l) 331 | } 332 | if len(links) > 0 { 333 | n = fmt.Sprintf("%s %s", strings.Join(links, " "), n) 334 | } 335 | if err := r.slack.PostMessage(ctx, n); err != nil { 336 | return err 337 | } 338 | if err := os.Setenv("GHDAG_ACTION_NOTIFY_SENT", n); err != nil { 339 | return err 340 | } 341 | return nil 342 | } 343 | 344 | var propagatableEnv = []string{ 345 | "GHDAG_ACTION_RUN_STDOUT", 346 | "GHDAG_ACTION_RUN_STDERR", 347 | "GHDAG_ACTION_LABELS_UPDATED", 348 | "GHDAG_ACTION_ASSIGNEES_UPDATED", 349 | "GHDAG_ACTION_REVIEWERS_UPDATED", 350 | "GHDAG_ACTION_COMMENT_CREATED", 351 | "GHDAG_ACTION_STATE_CHANGED", 352 | "GHDAG_ACTION_NOTIFY_SENT", 353 | "GHDAG_ACTION_DO_ERROR", 354 | } 355 | 356 | func (r *Runner) performNextAction(ctx context.Context, i *target.Target, t *task.Task, q chan TaskQueue, next []string) error { 357 | r.log(fmt.Sprintf("Call next task: %s", strings.Join(next, ", "))) 358 | 359 | callerEnv := env.Env{} 360 | for _, k := range propagatableEnv { 361 | if v, ok := os.LookupEnv(k); ok { 362 | callerEnv[k] = v 363 | } 364 | } 365 | 366 | for _, id := range next { 367 | nt, err := r.config.Tasks.Find(id) 368 | if err != nil { 369 | return err 370 | } 371 | q <- TaskQueue{ 372 | target: i, 373 | task: nt, 374 | called: true, 375 | callerTask: t, 376 | callerSeed: r.seed, 377 | callerExcludeKey: r.excludeKey, 378 | callerEnv: callerEnv, 379 | } 380 | } 381 | return nil 382 | } 383 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/antonmedv/expr" 17 | "github.com/k1LoW/ghdag/config" 18 | "github.com/k1LoW/ghdag/env" 19 | "github.com/k1LoW/ghdag/erro" 20 | "github.com/k1LoW/ghdag/gh" 21 | "github.com/k1LoW/ghdag/slk" 22 | "github.com/k1LoW/ghdag/target" 23 | "github.com/k1LoW/ghdag/task" 24 | "github.com/rs/zerolog/log" 25 | ) 26 | 27 | type Runner struct { 28 | mu sync.Mutex 29 | config *config.Config 30 | github gh.GhClient 31 | slack slk.SlkClient 32 | event *gh.GitHubEvent 33 | envCache []string 34 | logPrefix string 35 | seed int64 36 | excludeKey int 37 | } 38 | 39 | func New(c *config.Config) (*Runner, error) { 40 | e, _ := gh.DecodeGitHubEvent() 41 | if c == nil { 42 | c = config.New() 43 | } 44 | return &Runner{ 45 | config: c, 46 | github: nil, 47 | slack: nil, 48 | event: e, 49 | envCache: os.Environ(), 50 | logPrefix: "", 51 | seed: time.Now().UnixNano(), 52 | excludeKey: -1, 53 | }, nil 54 | } 55 | 56 | type TaskQueue struct { 57 | target *target.Target 58 | task *task.Task 59 | called bool 60 | callerTask *task.Task 61 | callerSeed int64 62 | callerExcludeKey int 63 | callerEnv env.Env 64 | } 65 | 66 | func (r *Runner) Run(ctx context.Context) error { 67 | r.logPrefix = "" 68 | r.log("Start session") 69 | r.log(fmt.Sprintf("github.event_name: %s", r.event.Name)) 70 | defer func() { 71 | _ = r.revertEnv() 72 | r.logPrefix = "" 73 | r.log("Session finished") 74 | }() 75 | if err := r.config.Env.Setenv(); err != nil { 76 | return err 77 | } 78 | 79 | if err := r.InitClients(); err != nil { 80 | return err 81 | } 82 | 83 | targets, err := r.fetchTargets(ctx) 84 | maxDigits := targets.MaxDigits() 85 | r.log(fmt.Sprintf("%d issues and pull requests are fetched", len(targets))) 86 | if errors.As(err, &erro.NotOpenError{}) { 87 | r.log(fmt.Sprintf("[SKIP] %s", err)) 88 | return nil 89 | } 90 | if err != nil { 91 | return err 92 | } 93 | tasks := r.config.Tasks 94 | r.log(fmt.Sprintf("%d tasks are loaded", len(tasks))) 95 | maxLength := tasks.MaxLengthID() 96 | 97 | q := make(chan TaskQueue, len(tasks)*len(targets)+100) 98 | for _, i := range targets { 99 | for _, t := range tasks { 100 | q <- TaskQueue{ 101 | target: i, 102 | task: t, 103 | } 104 | } 105 | } 106 | 107 | for { 108 | if len(q) == 0 { 109 | close(q) 110 | } 111 | 112 | tq, ok := <-q 113 | if !ok { 114 | break 115 | } 116 | 117 | err := func() error { 118 | r.mu.Lock() 119 | defer func() { 120 | _ = r.revertEnv() 121 | r.mu.Unlock() 122 | }() 123 | 124 | n := tq.target.Number 125 | id := tq.task.Id 126 | r.logPrefix = fmt.Sprintf(fmt.Sprintf("[#%%-%dd << %%-%ds] ", maxDigits, maxLength), n, id) 127 | 128 | if err := r.initTaskEnv(tq); err != nil { 129 | return err 130 | } 131 | 132 | if tq.called { 133 | // Update target 134 | target, err := r.github.FetchTarget(ctx, tq.target.Number) 135 | if err != nil { 136 | if errors.As(err, &erro.NotOpenError{}) { 137 | r.log(fmt.Sprintf("[SKIP] %s", err)) 138 | return nil 139 | } 140 | return err 141 | } 142 | tq.target = target 143 | 144 | // Set task id of caller 145 | if err := os.Setenv("GHDAG_CALLER_TASK_ID", tq.callerTask.Id); err != nil { 146 | return err 147 | } 148 | 149 | // Set caller seed 150 | r.seed = tq.callerSeed 151 | r.excludeKey = tq.callerExcludeKey 152 | } 153 | 154 | if tq.task.If != "" { 155 | if !r.CheckIf(tq.task.If, tq.target) { 156 | return nil 157 | } 158 | } else { 159 | if !tq.called { 160 | r.debuglog("[SKIP] the `if:` section is missing") 161 | return nil 162 | } 163 | } 164 | 165 | r.logPrefix = fmt.Sprintf(fmt.Sprintf("[#%%-%dd << %%-%ds] [DO] ", maxDigits, maxLength), n, id) 166 | if err := r.perform(ctx, tq.task.Do, tq.target, tq.task, q); err == nil { 167 | r.logPrefix = fmt.Sprintf(fmt.Sprintf("[#%%-%dd << %%-%ds] [OK] ", maxDigits, maxLength), n, id) 168 | if err := r.perform(ctx, tq.task.Ok, tq.target, tq.task, q); err != nil { 169 | r.initSeed() 170 | if errors.As(err, &erro.AlreadyInStateError{}) || errors.As(err, &erro.NoReviewerError{}) { 171 | r.log(fmt.Sprintf("[SKIP] %s", err)) 172 | return nil 173 | } 174 | r.errlog(fmt.Sprintf("%s", err)) 175 | return nil 176 | } 177 | } else { 178 | if errors.As(err, &erro.AlreadyInStateError{}) || errors.As(err, &erro.NoReviewerError{}) { 179 | r.log(fmt.Sprintf("[SKIP] %s", err)) 180 | return nil 181 | } 182 | r.errlog(fmt.Sprintf("%s", err)) 183 | if err := os.Setenv("GHDAG_ACTION_DO_ERROR", fmt.Sprintf("%s", err)); err != nil { 184 | return err 185 | } 186 | r.logPrefix = fmt.Sprintf(fmt.Sprintf("[#%%-%dd << %%-%ds] [NG] ", maxDigits, maxLength), n, id) 187 | if err := r.perform(ctx, tq.task.Ng, tq.target, tq.task, q); err != nil { 188 | if errors.As(err, &erro.AlreadyInStateError{}) || errors.As(err, &erro.NoReviewerError{}) { 189 | r.log(fmt.Sprintf("[SKIP] %s", err)) 190 | return nil 191 | } 192 | r.errlog(fmt.Sprintf("%s", err)) 193 | return nil 194 | } 195 | } 196 | return nil 197 | }() 198 | if err != nil { 199 | return err 200 | } 201 | } 202 | return nil 203 | } 204 | 205 | func (r *Runner) InitClients() error { 206 | if r.github == nil { 207 | gc, err := gh.NewClient() 208 | if err != nil { 209 | return err 210 | } 211 | r.github = gc 212 | } 213 | if r.slack == nil { 214 | sc, err := slk.NewClient() 215 | if err != nil { 216 | return err 217 | } 218 | r.slack = sc 219 | } 220 | return nil 221 | } 222 | 223 | func (r *Runner) CheckIf(cond string, i *target.Target) bool { 224 | if cond == "" { 225 | return false 226 | } 227 | isCalled := env.GetenvAsBool("GHDAG_TASK_IS_CALLED") 228 | now := time.Now() 229 | variables := map[string]interface{}{ 230 | "year": now.UTC().Year(), 231 | "month": now.UTC().Month(), 232 | "day": now.UTC().Day(), 233 | "hour": now.UTC().Hour(), 234 | "weekday": int(now.UTC().Weekday()), 235 | "is_called": isCalled, 236 | "github": map[string]interface{}{ 237 | "event_name": r.event.Name, 238 | "event": r.event.Payload, 239 | }, 240 | "env": env.EnvMap(), 241 | } 242 | for _, k := range propagatableEnv { 243 | v := os.Getenv(k) 244 | key := strings.ToLower(strings.Replace(k, "GHDAG_", "CALLER_", 1)) 245 | switch k { 246 | case "GHDAG_ACTION_LABELS_UPDATED", "GHDAG_ACTION_ASSIGNEES_UPDATED", "GHDAG_ACTION_REVIEWERS_UPDATED": 247 | a, _ := env.Split(v) 248 | variables[key] = a 249 | default: 250 | variables[key] = v 251 | } 252 | } 253 | variables = merge(variables, i.Dump()) 254 | 255 | if env.GetenvAsBool("DEBUG") { 256 | v, _ := json.MarshalIndent(variables, "", " ") 257 | r.debuglog(fmt.Sprintf("variables of `if:` section:\n%s", v)) 258 | } 259 | 260 | doOrNot, err := expr.Eval(fmt.Sprintf("(%s) == true", cond), variables) 261 | if err != nil { 262 | r.errlog(fmt.Sprintf("%s", err)) 263 | return false 264 | } 265 | if !doOrNot.(bool) { 266 | r.debuglog(fmt.Sprintf("[SKIP] the condition in the `if` section is not met (%s)", cond)) 267 | return false 268 | } 269 | return true 270 | } 271 | 272 | func (r *Runner) perform(ctx context.Context, a *task.Action, i *target.Target, t *task.Task, q chan TaskQueue) error { 273 | if a == nil { 274 | return nil 275 | } 276 | r.initSeed() 277 | 278 | switch { 279 | case a.Run != "": 280 | return r.PerformRunAction(ctx, i, a.Run) 281 | case len(a.Labels) > 0: 282 | return r.PerformLabelsAction(ctx, i, a.Labels) 283 | case len(a.Assignees) > 0 || (a.Assignees != nil && os.Getenv("GITHUB_ASSIGNEES") != ""): 284 | as, err := env.Split(os.Getenv("GITHUB_ASSIGNEES")) 285 | if err != nil { 286 | return err 287 | } 288 | assignees := unique(append(a.Assignees, as...)) 289 | return r.PerformAssigneesAction(ctx, i, assignees) 290 | case len(a.Reviewers) > 0 || (a.Reviewers != nil && os.Getenv("GITHUB_REVIEWERS") != ""): 291 | rs, err := env.Split(os.Getenv("GITHUB_REVIEWERS")) 292 | if err != nil { 293 | return err 294 | } 295 | reviewers := unique(append(a.Reviewers, rs...)) 296 | return r.PerformReviewersAction(ctx, i, reviewers) 297 | case a.Comment != "": 298 | return r.PerformCommentAction(ctx, i, a.Comment) 299 | case a.State != "": 300 | return r.PerformStateAction(ctx, i, a.State) 301 | case a.Notify != "": 302 | return r.PerformNotifyAction(ctx, i, a.Notify) 303 | case len(a.Next) > 0: 304 | return r.performNextAction(ctx, i, t, q, a.Next) 305 | } 306 | return nil 307 | } 308 | 309 | func (r *Runner) initSeed() { 310 | if !env.GetenvAsBool("GHDAG_SAMPLE_WITH_SAME_SEED") { 311 | r.seed = time.Now().UnixNano() 312 | r.excludeKey = -1 313 | } 314 | } 315 | 316 | func (r *Runner) initTaskEnv(tq TaskQueue) error { 317 | id := tq.task.Id 318 | dump := tq.target.Dump() 319 | for k, v := range dump { 320 | ek := strings.ToUpper(fmt.Sprintf("GHDAG_TARGET_%s", k)) 321 | switch v := v.(type) { 322 | case bool: 323 | ev := "true" 324 | if !v { 325 | ev = "false" 326 | } 327 | if err := os.Setenv(ek, ev); err != nil { 328 | return err 329 | } 330 | case float64: 331 | if err := os.Setenv(ek, fmt.Sprintf("%g", v)); err != nil { 332 | return err 333 | } 334 | case string: 335 | if err := os.Setenv(ek, v); err != nil { 336 | return err 337 | } 338 | case []interface{}: 339 | ev := []string{} 340 | for _, i := range v { 341 | ev = append(ev, i.(string)) 342 | } 343 | if err := os.Setenv(ek, strings.Join(ev, ", ")); err != nil { 344 | return err 345 | } 346 | } 347 | } 348 | if err := os.Setenv("GHDAG_TASK_ID", id); err != nil { 349 | return err 350 | } 351 | 352 | var isCalled string 353 | if tq.called { 354 | isCalled = "1" 355 | } else { 356 | isCalled = "0" 357 | } 358 | if err := os.Setenv("GHDAG_TASK_IS_CALLED", isCalled); err != nil { 359 | return err 360 | } 361 | if err := r.config.Env.Setenv(); err != nil { 362 | return err 363 | } 364 | if err := tq.task.Env.Setenv(); err != nil { 365 | return err 366 | } 367 | if tq.called { 368 | if err := tq.callerEnv.Setenv(); err != nil { 369 | return err 370 | } 371 | } 372 | return nil 373 | } 374 | 375 | func (r *Runner) fetchTargets(ctx context.Context) (target.Targets, error) { 376 | en := os.Getenv("GITHUB_EVENT_NAME") 377 | if strings.HasPrefix(en, "issue") || strings.HasPrefix(en, "pull_request") { 378 | t, err := r.FetchTarget(ctx, 0) 379 | if err != nil { 380 | return nil, err 381 | } 382 | return target.Targets{t.Number: t}, nil 383 | } 384 | r.log(fmt.Sprintf("Fetch all open issues and pull requests from %s", os.Getenv("GITHUB_REPOSITORY"))) 385 | return r.github.FetchTargets(ctx) 386 | } 387 | 388 | func (r *Runner) FetchTarget(ctx context.Context, n int) (*target.Target, error) { 389 | if n > 0 { 390 | return r.github.FetchTarget(ctx, n) 391 | } 392 | if !strings.HasPrefix(r.event.Name, "issue") && !strings.HasPrefix(r.event.Name, "pull_request") { 393 | return nil, fmt.Errorf("unsupported event: %s", r.event.Name) 394 | } 395 | if r.event.State != "open" { 396 | return nil, erro.NewNotOpenError(fmt.Errorf("#%d is %s", n, r.event.State)) 397 | } 398 | r.log(fmt.Sprintf("Fetch #%d from %s", r.event.Number, os.Getenv("GITHUB_REPOSITORY"))) 399 | return r.github.FetchTarget(ctx, r.event.Number) 400 | } 401 | 402 | func (r *Runner) setExcludeKey(in []string, exclude string) error { 403 | for k, v := range in { 404 | if v == exclude { 405 | r.excludeKey = k 406 | return nil 407 | } 408 | } 409 | return fmt.Errorf("not found key: %s", exclude) 410 | } 411 | 412 | func (r *Runner) sample(in []string, envKey string) ([]string, error) { 413 | if r.excludeKey >= 0 { 414 | in = unset(in, r.excludeKey) 415 | } 416 | if os.Getenv(envKey) == "" { 417 | return in, nil 418 | } 419 | r.debuglog(fmt.Sprintf("env %s is set for sampling", envKey)) 420 | sn, err := strconv.Atoi(os.Getenv(envKey)) 421 | if err != nil { 422 | return nil, err 423 | } 424 | 425 | if len(in) > sn { 426 | rand.Seed(r.seed) 427 | rand.Shuffle(len(in), func(i, j int) { in[i], in[j] = in[j], in[i] }) 428 | in = in[:sn] 429 | } 430 | return in, nil 431 | } 432 | 433 | func (r *Runner) log(m string) { 434 | log.Info().Msg(fmt.Sprintf("%s%s", r.logPrefix, m)) 435 | } 436 | 437 | func (r *Runner) errlog(m string) { 438 | log.Error().Msg(fmt.Sprintf("%s%s", r.logPrefix, m)) 439 | } 440 | 441 | func (r *Runner) debuglog(m string) { 442 | log.Debug().Msg(fmt.Sprintf("%s%s", r.logPrefix, m)) 443 | } 444 | 445 | func (r *Runner) revertEnv() error { 446 | return env.Revert(r.envCache) 447 | } 448 | 449 | func unique(in []string) []string { 450 | m := map[string]struct{}{} 451 | u := []string{} 452 | for _, s := range in { 453 | if _, ok := m[s]; ok { 454 | continue 455 | } 456 | u = append(u, s) 457 | m[s] = struct{}{} 458 | } 459 | return u 460 | } 461 | 462 | func contains(s []string, e string) bool { 463 | for _, v := range s { 464 | if e == v { 465 | return true 466 | } 467 | } 468 | return false 469 | } 470 | 471 | func unset(s []string, i int) []string { 472 | if i >= len(s) { 473 | return s 474 | } 475 | return append(s[:i], s[i+1:]...) 476 | } 477 | 478 | func exclude(s []string, e string) []string { 479 | o := []string{} 480 | for _, v := range s { 481 | if v == e { 482 | continue 483 | } 484 | o = append(o, v) 485 | } 486 | return o 487 | } 488 | 489 | func merge(ms ...map[string]interface{}) map[string]interface{} { 490 | o := map[string]interface{}{} 491 | for _, m := range ms { 492 | for k, v := range m { 493 | o[k] = v 494 | } 495 | } 496 | return o 497 | } 498 | 499 | func sortStringSlice(in []string) { 500 | sort.Slice(in, func(i, j int) bool { 501 | return in[i] < in[j] 502 | }) 503 | } 504 | -------------------------------------------------------------------------------- /runner/actions_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/bxcodec/faker/v3" 14 | "github.com/golang/mock/gomock" 15 | "github.com/k1LoW/ghdag/env" 16 | "github.com/k1LoW/ghdag/erro" 17 | "github.com/k1LoW/ghdag/mock" 18 | "github.com/k1LoW/ghdag/target" 19 | ) 20 | 21 | func TestPerformRunAction(t *testing.T) { 22 | r, err := New(nil) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | defer func() { 27 | if err := r.revertEnv(); err != nil { 28 | t.Fatal(err) 29 | } 30 | }() 31 | 32 | tests := []struct { 33 | in string 34 | wantStdout string 35 | wantStderr string 36 | wantErr bool 37 | }{ 38 | {"echo hello", "hello\n", "", false}, 39 | {"echo world 1>&2", "", "world\n", false}, 40 | {"unknowncmd", "", "not found\n", true}, 41 | } 42 | for _, tt := range tests { 43 | if err := r.revertEnv(); err != nil { 44 | t.Fatal(err) 45 | } 46 | ctx := context.Background() 47 | i := &target.Target{} 48 | if err := faker.FakeData(i); err != nil { 49 | t.Fatal(err) 50 | } 51 | if err := r.PerformRunAction(ctx, i, tt.in); (err == nil) == tt.wantErr { 52 | t.Errorf("got %v\nwantErr %v", err, tt.wantErr) 53 | } 54 | if got := os.Getenv("GHDAG_ACTION_RUN_STDOUT"); !strings.Contains(got, tt.wantStdout) { 55 | t.Errorf("got %v\nwant %v", got, tt.wantStdout) 56 | } 57 | if got := os.Getenv("GHDAG_ACTION_RUN_STDERR"); !strings.Contains(got, tt.wantStderr) { 58 | t.Errorf("got %v\nwant %v", got, tt.wantStderr) 59 | } 60 | } 61 | } 62 | 63 | func TestPerformLabelsAction(t *testing.T) { 64 | ctrl := gomock.NewController(t) 65 | defer ctrl.Finish() 66 | 67 | r, err := New(nil) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | defer func() { 72 | if err := r.revertEnv(); err != nil { 73 | t.Fatal(err) 74 | } 75 | }() 76 | 77 | m := mock.NewMockGhClient(ctrl) 78 | r.github = m 79 | 80 | tests := []struct { 81 | in []string 82 | current []string 83 | behavior string 84 | want []string 85 | wantErr interface{} 86 | }{ 87 | {[]string{"bug", "question"}, nil, "", []string{"bug", "question"}, nil}, 88 | {[]string{"bug", "question"}, []string{"bug", "question"}, "", []string{"bug", "question"}, &erro.AlreadyInStateError{}}, 89 | {[]string{"bug", "question"}, nil, "replace", []string{"bug", "question"}, nil}, 90 | {[]string{"bug", "question"}, []string{"help wanted"}, "replace", []string{"bug", "question"}, nil}, 91 | {[]string{"bug", "question"}, []string{"help wanted"}, "add", []string{"bug", "help wanted", "question"}, nil}, 92 | {[]string{"bug", "question"}, []string{"bug", "help wanted"}, "remove", []string{"help wanted"}, nil}, 93 | } 94 | for _, tt := range tests { 95 | if err := r.revertEnv(); err != nil { 96 | t.Fatal(err) 97 | } 98 | if err := os.Setenv("GHDAG_ACTION_LABELS_BEHAVIOR", tt.behavior); err != nil { 99 | t.Fatal(err) 100 | } 101 | ctx := context.Background() 102 | i := &target.Target{} 103 | if err := faker.FakeData(i); err != nil { 104 | t.Fatal(err) 105 | } 106 | if tt.current != nil { 107 | i.Labels = tt.current 108 | } 109 | if tt.wantErr == nil { 110 | m.EXPECT().SetLabels(gomock.Eq(ctx), gomock.Eq(i.Number), gomock.Eq(tt.want)).Return(nil) 111 | if err := r.PerformLabelsAction(ctx, i, tt.in); err != nil { 112 | t.Error(err) 113 | } 114 | } else { 115 | if err := r.PerformLabelsAction(ctx, i, tt.in); !errors.As(err, tt.wantErr) { 116 | t.Errorf("got %v\nwant %v", err, tt.wantErr) 117 | } 118 | } 119 | if got := os.Getenv("GHDAG_ACTION_LABELS_UPDATED"); got != env.Join(tt.want) { 120 | t.Errorf("got %v\nwant %v", got, env.Join(tt.want)) 121 | } 122 | } 123 | } 124 | 125 | func TestPerformAssigneesAction(t *testing.T) { 126 | ctrl := gomock.NewController(t) 127 | defer ctrl.Finish() 128 | 129 | r, err := New(nil) 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | defer func() { 134 | if err := r.revertEnv(); err != nil { 135 | t.Fatal(err) 136 | } 137 | }() 138 | 139 | m := mock.NewMockGhClient(ctrl) 140 | r.github = m 141 | 142 | tests := []struct { 143 | in []string 144 | current []string 145 | behavior string 146 | want []string 147 | wantErr interface{} 148 | }{ 149 | {[]string{"alice", "bob"}, nil, "", []string{"alice", "bob"}, nil}, 150 | {[]string{"alice", "bob"}, []string{"alice", "bob"}, "", []string{"alice", "bob"}, &erro.AlreadyInStateError{}}, 151 | {[]string{"alice", "bob"}, nil, "add", []string{"alice", "bob"}, nil}, 152 | {[]string{"bob"}, []string{"alice"}, "add", []string{"alice", "bob"}, nil}, 153 | {[]string{"alice", "bob"}, []string{"alice"}, "add", []string{"alice", "bob"}, nil}, 154 | {[]string{"alice"}, []string{"alice", "bob"}, "remove", []string{"bob"}, nil}, 155 | } 156 | for _, tt := range tests { 157 | if err := r.revertEnv(); err != nil { 158 | t.Fatal(err) 159 | } 160 | if err := os.Setenv("GHDAG_ACTION_ASSIGNEES_BEHAVIOR", tt.behavior); err != nil { 161 | t.Fatal(err) 162 | } 163 | ctx := context.Background() 164 | i := &target.Target{} 165 | if err := faker.FakeData(i); err != nil { 166 | t.Fatal(err) 167 | } 168 | i.Assignees = tt.current 169 | m.EXPECT().ResolveUsers(gomock.Eq(ctx), gomock.Eq(tt.in)).Return(tt.in, nil) 170 | if tt.wantErr == nil { 171 | m.EXPECT().SetAssignees(gomock.Eq(ctx), gomock.Eq(i.Number), gomock.Eq(tt.want)).Return(nil) 172 | } 173 | if err := r.PerformAssigneesAction(ctx, i, tt.in); err != nil { 174 | if !errors.As(err, tt.wantErr) { 175 | t.Errorf("got %v\nwant %v", err, tt.wantErr) 176 | } 177 | } 178 | if got := os.Getenv("GHDAG_ACTION_ASSIGNEES_UPDATED"); got != env.Join(tt.want) { 179 | t.Errorf("got %v\nwant %v", got, env.Join(tt.want)) 180 | } 181 | } 182 | } 183 | 184 | func TestPerformReviewersAction(t *testing.T) { 185 | ctrl := gomock.NewController(t) 186 | defer ctrl.Finish() 187 | 188 | r, err := New(nil) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | defer func() { 193 | if err := r.revertEnv(); err != nil { 194 | t.Fatal(err) 195 | } 196 | }() 197 | 198 | m := mock.NewMockGhClient(ctrl) 199 | r.github = m 200 | 201 | tests := []struct { 202 | in []string 203 | author string 204 | current []string 205 | currentCodeOwners []string 206 | want []string 207 | wantErr interface{} 208 | }{ 209 | {[]string{"alice", "bob"}, "", nil, nil, []string{"alice", "bob"}, nil}, 210 | {[]string{"alice", "bob"}, "", []string{"alice", "bob"}, nil, []string{"alice", "bob"}, &erro.AlreadyInStateError{}}, 211 | {[]string{"alice", "bob"}, "", []string{"alice"}, nil, []string{"alice", "bob"}, nil}, 212 | {[]string{"alice", "bob"}, "", []string{}, []string{"bob"}, []string{"alice"}, nil}, 213 | {[]string{"alice", "bob"}, "alice", nil, nil, []string{"bob"}, nil}, 214 | {[]string{"alice"}, "alice", nil, nil, nil, &erro.NoReviewerError{}}, 215 | } 216 | for _, tt := range tests { 217 | if err := r.revertEnv(); err != nil { 218 | t.Fatal(err) 219 | } 220 | ctx := context.Background() 221 | i := &target.Target{} 222 | if err := faker.FakeData(i); err != nil { 223 | t.Fatal(err) 224 | } 225 | if tt.author != "" { 226 | i.Author = tt.author 227 | } 228 | if tt.current != nil { 229 | i.Reviewers = tt.current 230 | i.CodeOwners = tt.currentCodeOwners 231 | } 232 | if tt.wantErr == nil { 233 | m.EXPECT().SetReviewers(gomock.Eq(ctx), gomock.Eq(i.Number), gomock.Eq(tt.want)).Return(nil) 234 | } 235 | if err := r.PerformReviewersAction(ctx, i, tt.in); err != nil { 236 | if !errors.As(err, tt.wantErr) { 237 | t.Errorf("got %v\nwant %v", err, tt.wantErr) 238 | } 239 | } 240 | if got := os.Getenv("GHDAG_ACTION_REVIEWERS_UPDATED"); got != env.Join(tt.want) { 241 | t.Errorf("got %v\nwant %v", got, env.Join(tt.want)) 242 | } 243 | } 244 | } 245 | 246 | func TestPerformCommentAction(t *testing.T) { 247 | ctrl := gomock.NewController(t) 248 | defer ctrl.Finish() 249 | 250 | r, err := New(nil) 251 | if err != nil { 252 | t.Fatal(err) 253 | } 254 | defer func() { 255 | if err := r.revertEnv(); err != nil { 256 | t.Fatal(err) 257 | } 258 | }() 259 | 260 | m := mock.NewMockGhClient(ctrl) 261 | r.github = m 262 | 263 | tests := []struct { 264 | in string 265 | mentionsEnv string 266 | current string 267 | want string 268 | wantErr interface{} 269 | }{ 270 | {"hello", "", "", "hello", nil}, 271 | {"hello", "alice @bob", "", "@alice @bob hello", nil}, 272 | {"hello", "", "hello", "", &erro.AlreadyInStateError{}}, 273 | } 274 | for _, tt := range tests { 275 | if err := r.revertEnv(); err != nil { 276 | t.Fatal(err) 277 | } 278 | ctx := context.Background() 279 | i := &target.Target{} 280 | if err := faker.FakeData(i); err != nil { 281 | t.Fatal(err) 282 | } 283 | i.NumberOfConsecutiveComments = 1 284 | i.LatestCommentBody = tt.current 285 | if err := os.Setenv("GITHUB_COMMENT_MENTIONS", tt.mentionsEnv); err != nil { 286 | t.Fatal(err) 287 | } 288 | if tt.wantErr == nil { 289 | m.EXPECT().AddComment(gomock.Eq(ctx), gomock.Eq(i.Number), gomock.Eq(tt.want)).Return(nil) 290 | } 291 | if err := r.PerformCommentAction(ctx, i, tt.in); err != nil { 292 | if !errors.As(err, tt.wantErr) { 293 | t.Errorf("got %v\nwant %v", err, tt.wantErr) 294 | } 295 | } 296 | if got := os.Getenv("GHDAG_ACTION_COMMENT_CREATED"); got != tt.want { 297 | t.Errorf("got %v\nwant %v", got, tt.want) 298 | } 299 | } 300 | } 301 | 302 | func TestPerformStateAction(t *testing.T) { 303 | ctrl := gomock.NewController(t) 304 | defer ctrl.Finish() 305 | 306 | r, err := New(nil) 307 | if err != nil { 308 | t.Fatal(err) 309 | } 310 | defer func() { 311 | if err := r.revertEnv(); err != nil { 312 | t.Fatal(err) 313 | } 314 | }() 315 | 316 | m := mock.NewMockGhClient(ctrl) 317 | r.github = m 318 | 319 | tests := []struct { 320 | in string 321 | want string 322 | wantErr bool 323 | }{ 324 | {"close", "closed", false}, 325 | {"merge", "merged", false}, 326 | {"revert", "", true}, 327 | } 328 | for _, tt := range tests { 329 | if err := r.revertEnv(); err != nil { 330 | t.Fatal(err) 331 | } 332 | ctx := context.Background() 333 | i := &target.Target{} 334 | if err := faker.FakeData(i); err != nil { 335 | t.Fatal(err) 336 | } 337 | switch tt.in { 338 | case "close", "closed": 339 | m.EXPECT().CloseIssue(gomock.Eq(ctx), gomock.Eq(i.Number)).Return(nil) 340 | if err := r.PerformStateAction(ctx, i, tt.in); err != nil { 341 | t.Error(err) 342 | } 343 | case "merge", "merged": 344 | m.EXPECT().MergePullRequest(gomock.Eq(ctx), gomock.Eq(i.Number)).Return(nil) 345 | if err := r.PerformStateAction(ctx, i, tt.in); err != nil { 346 | t.Error(err) 347 | } 348 | default: 349 | if err := r.PerformStateAction(ctx, i, tt.in); (err != nil) != tt.wantErr { 350 | t.Errorf("got %v\nwant %v", err, tt.wantErr) 351 | } 352 | } 353 | if got := os.Getenv("GHDAG_ACTION_STATE_CHANGED"); got != tt.want { 354 | t.Errorf("got %v\nwant %v", got, tt.want) 355 | } 356 | } 357 | } 358 | 359 | func TestPerformNotifyAction(t *testing.T) { 360 | ctrl := gomock.NewController(t) 361 | defer ctrl.Finish() 362 | 363 | r, err := New(nil) 364 | if err != nil { 365 | t.Fatal(err) 366 | } 367 | defer func() { 368 | if err := r.revertEnv(); err != nil { 369 | t.Fatal(err) 370 | } 371 | }() 372 | 373 | m := mock.NewMockSlkClient(ctrl) 374 | r.slack = m 375 | 376 | tests := []struct { 377 | in string 378 | mentionsEnv string 379 | want string 380 | wantMentions []string 381 | wantErr interface{} 382 | }{ 383 | {"hello", "", "hello", []string{}, nil}, 384 | {"hello", "alice", " hello", []string{"alice"}, nil}, 385 | {"hello", "alice bob", " hello", []string{"alice", "bob"}, nil}, 386 | } 387 | for _, tt := range tests { 388 | if err := r.revertEnv(); err != nil { 389 | t.Fatal(err) 390 | } 391 | ctx := context.Background() 392 | i := &target.Target{} 393 | if err := faker.FakeData(i); err != nil { 394 | t.Fatal(err) 395 | } 396 | if err := os.Setenv("SLACK_API_TOKEN", "dummy"); err != nil { 397 | t.Fatal(err) 398 | } 399 | if err := os.Setenv("SLACK_MENTIONS", tt.mentionsEnv); err != nil { 400 | t.Fatal(err) 401 | } 402 | if tt.wantErr == nil { 403 | m.EXPECT().PostMessage(gomock.Eq(ctx), gomock.Eq(tt.want)).Return(nil) 404 | for _, mention := range tt.wantMentions { 405 | m.EXPECT().GetMentionLinkByName(gomock.Eq(ctx), gomock.Eq(mention)).Return(fmt.Sprintf("", strings.ToUpper(mention)), nil) 406 | } 407 | } 408 | if err := r.PerformNotifyAction(ctx, i, tt.in); err != nil { 409 | t.Error(err) 410 | } 411 | if got := os.Getenv("GHDAG_ACTION_NOTIFY_SENT"); got != tt.want { 412 | t.Errorf("got %v\nwant %v", got, tt.want) 413 | } 414 | } 415 | } 416 | 417 | func TestSetReviewersAndNotify(t *testing.T) { 418 | ctrl := gomock.NewController(t) 419 | defer ctrl.Finish() 420 | 421 | r, err := New(nil) 422 | if err != nil { 423 | t.Fatal(err) 424 | } 425 | defer func() { 426 | if err := r.revertEnv(); err != nil { 427 | t.Fatal(err) 428 | } 429 | }() 430 | mg := mock.NewMockGhClient(ctrl) 431 | ms := mock.NewMockSlkClient(ctrl) 432 | r.github = mg 433 | r.slack = ms 434 | 435 | tests := []struct { 436 | authorExist bool 437 | enableSameSeed bool 438 | }{ 439 | {false, true}, 440 | {true, true}, 441 | } 442 | for _, tt := range tests { 443 | if err := r.revertEnv(); err != nil { 444 | t.Fatal(err) 445 | } 446 | ctx := context.Background() 447 | i := &target.Target{} 448 | if err := faker.FakeData(i); err != nil { 449 | t.Fatal(err) 450 | } 451 | if err := os.Setenv("GHDAG_SAMPLE_WITH_SAME_SEED", fmt.Sprintf("%t", tt.enableSameSeed)); err != nil { 452 | t.Fatal(err) 453 | } 454 | if err := os.Setenv("SLACK_API_TOKEN", "dummy"); err != nil { 455 | t.Fatal(err) 456 | } 457 | c := 10 458 | users := []string{} 459 | for i := 0; i < c; i++ { 460 | users = append(users, fmt.Sprintf("user%d", i)) 461 | } 462 | if tt.authorExist { 463 | i.Author = users[rand.Intn(len(users)-1)] 464 | } 465 | sample := rand.Intn(c-2) + 2 466 | if err := os.Setenv("GITHUB_REVIEWERS_SAMPLE", fmt.Sprintf("%d", sample)); err != nil { 467 | t.Fatal(err) 468 | } 469 | if err := os.Setenv("SLACK_MENTIONS", env.Join(users)); err != nil { 470 | t.Fatal(err) 471 | } 472 | 473 | mg.EXPECT().SetReviewers(gomock.Eq(ctx), gomock.Eq(i.Number), gomock.Any()).Return(nil) 474 | r.initSeed() 475 | if err := r.PerformReviewersAction(ctx, i, users); err != nil { 476 | t.Errorf("got %v", err) 477 | } 478 | 479 | want, err := env.Split(os.Getenv("GHDAG_ACTION_REVIEWERS_UPDATED")) 480 | if err != nil { 481 | t.Fatal(err) 482 | } 483 | 484 | if sample != len(want) { 485 | t.Errorf("got %v\nwant %v", sample, len(want)) 486 | } 487 | 488 | if err := os.Setenv("SLACK_MENTIONS_SAMPLE", fmt.Sprintf("%d", sample)); err != nil { 489 | t.Fatal(err) 490 | } 491 | ms.EXPECT().PostMessage(gomock.Eq(ctx), gomock.Any()).Return(nil) 492 | for _, mu := range want { 493 | ms.EXPECT().GetMentionLinkByName(gomock.Eq(ctx), gomock.Eq(mu)).Return("", nil) 494 | } 495 | r.initSeed() 496 | if err := r.PerformNotifyAction(ctx, i, "Hello"); err != nil { 497 | t.Errorf("got %v", err) 498 | } 499 | } 500 | } 501 | 502 | func TestPerformRunActionRetry(t *testing.T) { 503 | r, err := New(nil) 504 | if err != nil { 505 | t.Fatal(err) 506 | } 507 | defer func() { 508 | if err := r.revertEnv(); err != nil { 509 | t.Fatal(err) 510 | } 511 | }() 512 | 513 | d := t.TempDir() 514 | 515 | tests := []struct { 516 | max string 517 | timeout string 518 | minmax string 519 | want string 520 | }{ 521 | {"", "", "", "run\n"}, 522 | {"0", "", "", "run\n"}, 523 | {"0", "5min", "", "run\n"}, 524 | {"2", "", "", "run\nrun\nrun\n"}, 525 | {"3", "0.01sec", "", "run\n"}, 526 | {"", "0.01sec", "", "run\n"}, 527 | {"", "0.25sec", "0.5sec", "run\n"}, 528 | } 529 | for idx, tt := range tests { 530 | if err := r.revertEnv(); err != nil { 531 | t.Fatal(err) 532 | } 533 | ctx := context.Background() 534 | i := &target.Target{} 535 | if err := faker.FakeData(i); err != nil { 536 | t.Fatal(err) 537 | } 538 | if err := os.Setenv("GHDAG_ACTION_RUN_RETRY_MAX", tt.max); err != nil { 539 | t.Fatal(err) 540 | } 541 | if err := os.Setenv("GHDAG_ACTION_RUN_RETRY_TIMEOUT", tt.timeout); err != nil { 542 | t.Fatal(err) 543 | } 544 | if err := os.Setenv("GHDAG_ACTION_RUN_RETRY_MIN_INTERVAL", tt.minmax); err != nil { 545 | t.Fatal(err) 546 | } 547 | if err := os.Setenv("GHDAG_ACTION_RUN_RETRY_MAX_INTERVAL", tt.minmax); err != nil { 548 | t.Fatal(err) 549 | } 550 | 551 | p := filepath.Join(d, fmt.Sprintf("%d_TestPerformRunActionRetry.txt", idx)) 552 | command := fmt.Sprintf("perl -MTime::HiRes=sleep -e sleep -e 0.1 && echo 'run' >> %s && exit 1", p) 553 | if err := r.PerformRunAction(ctx, i, command); err == nil { 554 | t.Errorf("got %v want error", err) 555 | } 556 | if _, err := os.Stat(p); err != nil { 557 | // command canceled 558 | continue 559 | } 560 | b, err := os.ReadFile(p) 561 | if err != nil { 562 | t.Fatal(err) 563 | } 564 | got := string(b) 565 | if got != tt.want { 566 | t.Errorf("%#v:\ngot %v\nwant %v", tt, got, tt.want) 567 | } 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghdag 2 | 3 | [![build](https://github.com/k1LoW/ghdag/actions/workflows/ci.yml/badge.svg)](https://github.com/k1LoW/ghdag/actions) [![ghdag workflow](https://github.com/k1LoW/ghdag/actions/workflows/ghdag_workflow.yml/badge.svg)](https://github.com/k1LoW/ghdag/actions/workflows/ghdag_workflow.yml) 4 | 5 | :octocat: `ghdag` is a tiny workflow engine for GitHub issue and pull request. 6 | 7 | Key features of `ghdag` are: 8 | 9 | - Simple definition of workflows to improve the lifecycle of issues and pull requests. 10 | - Built-in GitHub and Slack actions. 11 | - Optimized for running on GitHub Actions, it is easier to configure when running on GitHub Actions. 12 | 13 | See **[Examples](examples.md)** page for practical usage. 14 | 15 | ## Getting Started 16 | 17 | ### Generate a workflow file 18 | 19 | Execute `ghdag init` for generating a workflow YAML file for `ghdag` ( and a workflow YAML file for GitHub Actions ). 20 | 21 | ``` console 22 | $ ghdag init myworkflow 23 | 2021-02-23T23:29:48+09:00 [INFO] ghdag version 0.2.3 24 | 2021-02-23T23:29:48+09:00 [INFO] Creating myworkflow.yml 25 | Do you generate a workflow YAML file for GitHub Actions? (y/n) [y]: y 26 | 2021-02-23T23:29:48+09:00 [INFO] Creating .github/workflows/ghdag_workflow.yml 27 | $ cat myworkflow.yml 28 | --- 29 | # generate by ghdag init 30 | tasks: 31 | - 32 | id: set-question-label 33 | if: 'is_issue && len(labels) == 0 && title endsWith "?"' 34 | do: 35 | labels: [question] 36 | ok: 37 | run: echo 'Set labels' 38 | ng: 39 | run: echo 'failed' 40 | name: Set 'question' label 41 | $ cat .github/workflows/ghdag_workflow.yml 42 | --- 43 | # generate by ghdag init 44 | name: ghdag workflow 45 | on: 46 | issues: 47 | types: [opened] 48 | issue_comment: 49 | types: [created] 50 | pull_request: 51 | types: [opened] 52 | 53 | jobs: 54 | run-workflow: 55 | name: Run workflow 56 | runs-on: ubuntu-latest 57 | container: ghcr.io/k1low/ghdag:latest 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v2 61 | with: 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | - name: Run ghdag 64 | run: ghdag run myworkflow.yml 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | ``` 68 | 69 | And edit myworkflow.yml. 70 | 71 | ### Run workflow on your machine 72 | 73 | ``` console 74 | $ export GITHUB_TOKEN=xxXxXXxxXXxx 75 | $ export GITHUB_REPOGITORY=k1LoW/myrepo 76 | $ ghdag run myworkflow.yml 77 | 2021-02-28T00:26:41+09:00 [INFO] ghdag version 0.2.3 78 | 2021-02-28T00:26:41+09:00 [INFO] Start session 79 | 2021-02-28T00:26:41+09:00 [INFO] Fetch all open issues and pull requests from k1LoW/myrepo 80 | 2021-02-28T00:26:42+09:00 [INFO] 3 issues and pull requests are fetched 81 | 2021-02-28T00:26:42+09:00 [INFO] 1 tasks are loaded 82 | 2021-02-28T00:26:42+09:00 [INFO] [#14 << set-question-label] [DO] Replace labels: question 83 | Set labels 84 | 2021-02-28T00:26:43+09:00 [INFO] Session finished 85 | $ 86 | ``` 87 | 88 | ### Run workflow on GitHub Actions 89 | 90 | ``` console 91 | $ git add myworkflow.yml 92 | $ git add .github/workflows/ghdag_workflow.yml 93 | $ git commit -m'Initial commit for ghdag workflow' 94 | $ git push origin 95 | ``` 96 | 97 | And, see `https://github.com/[owner]/[repo]/actions` 98 | 99 | #### Issues and pull requests targeted by ghdag 100 | 101 | :memo: The issues and pull requests that `ghdag` fetches varies depending on the environment in which it is run. 102 | 103 | | Environment or [Event that trigger workflows](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target) for GitHub Actions | Issues and pull requests fetched by `ghdag` | 104 | | --- | --- | 105 | | On your machine | **All** `opened` and `not draft` issues and pull requests | 106 | | `issues` | A **single** `opened` issue that triggered the event | 107 | | `issue_comment` | A **single** `opened` and `not draft` issue or pull request that triggered the event | 108 | | `pull_request` `pull_request_*` | A **single** `opened` and `not draft` pull request that triggered the event | 109 | | Other events | **All** `opened` and `not draft` issues and pull requests | 110 | 111 | ## Workflow syntax 112 | 113 | `ghdag` requires a YAML file to define your workflow configuration. 114 | 115 | #### `env:` 116 | 117 | A map of environment environment variables in global scope (available to all tasks in the workflow). 118 | 119 | **Example** 120 | 121 | ``` yaml 122 | env: 123 | SERVER: production 124 | GITHUB_TOKEN: ${GHDAG_GITHUB_TOKEN} 125 | ``` 126 | 127 | #### `tasks:` 128 | 129 | A workflow run is made up of one or more tasks. Tasks run in sequentially. 130 | 131 | **Example** 132 | 133 | ``` yaml 134 | tasks: 135 | - 136 | id: first 137 | if: 'is_issue && len(labels) == 0 && title endsWith "?"' 138 | do: 139 | labels: [question] 140 | ok: 141 | run: echo 'Set labels' 142 | ng: 143 | run: echo 'failed' 144 | name: Set 'question' label 145 | - 146 | id: second 147 | if: len(assignees) == 0 148 | do: 149 | assignees: [k1LoW] 150 | name: Assign me 151 | ``` 152 | 153 | #### `tasks[*].id:` 154 | 155 | Each task have an id to be called by another task. 156 | 157 | #### `tasks[*].name:` 158 | 159 | Name of task. 160 | 161 | #### `tasks[*].if:` 162 | 163 | The task will not be performed unless the condition in the `if` section is met. 164 | 165 | If the `if` section is missing, the task will not be performed unless it is called by another task. 166 | 167 | ##### Expression syntax 168 | 169 | `ghdag` use [antonmedv/expr](https://github.com/antonmedv/expr). 170 | 171 | See [Language Definition](https://github.com/antonmedv/expr/blob/master/docs/Language-Definition.md). 172 | 173 | ##### Available variables 174 | 175 | The variables available in the `if` section are as follows 176 | 177 | | Variable name | Type | Description | 178 | | --- | --- | --- | 179 | | `number` | `int` | Number of the issue (pull request) | 180 | | `state` | `string` | State of the issue (pull request) | 181 | | `title` | `string` | Title of the issue (pull request) | 182 | | `body` | `string` | Body of the issue (pull request) | 183 | | `url` | `string` | URL of the issue (pull request) | 184 | | `author` | `string` | Author of the issue (pull request) | 185 | | `labels` | `array` | Labels that are set for the issue | 186 | | `assignees` | `array` | Assignees of the issue (pull request) | 187 | | `reviewers` | `array` | Reviewers of the pull request (including code owners) | 188 | | `code_owners` | `array` | Code owners of the pull request | 189 | | `reviewers_who_approved` | `array` | Reviewers who approved the pull request (including code owners) | 190 | | `code_owners_who_approved` | `array` | Code owners who approved the pull request | 191 | | `is_issue` | `bool` | `true` if the target type of the workflow is "Issue" | 192 | | `is_pull_request` | `bool` | `true` if the target type of the workflow is "Pull request" | 193 | | `is_approved` | `bool` | `true` if the pull request has been approved ( `Require pull request reviews before merging` option must be enabled ) | 194 | | `is_review_required` | `bool` | `true` if a review is required before the pull request can be merged ( `Require pull request reviews before merging` option must be enabled ) | 195 | | `is_change_requested` | `bool` | `true` if changes have been requested on the pull request ( `Require pull request reviews before merging` option must be enabled ) | 196 | | `mergeable` | `bool` | `true` if the pull request can be merged. | 197 | | `changed_files` | `int` | Number of changed files in this pull request | 198 | | `hours_elapsed_since_created` | `int` | Hours elspsed since the issue (pull request) created | 199 | | `hours_elapsed_since_updated` | `int` | Hours elspsed since the issue (pull request) updated | 200 | | `number_of_comments` | `int` | Number of comments | 201 | | `latest_comment_author` | `string` | Author of latest comment | 202 | | `latest_comment_body` | `string` | Body of latest comment | 203 | | `login` | `string` | User of `GITHUB_TOKEN` | 204 | | `is_called` | `true` | `true` if the target is called | 205 | | `caller_action_run_stdout` | `string` | Latest caller STDOUT of the `run` action | 206 | | `caller_action_run_stderr` | `string` | Latest caller STDERR of the `run` action | 207 | | `caller_action_labels_updated` | `array` | Latest caller update result of the `labels:` action | 208 | | `caller_action_assignees_updated` | `array` | Latest caller update result of the `assgnees:` action | 209 | | `caller_action_reviewers_updated` | `array` | Latest caller update result of the `reviewers:` action | 210 | | `caller_action_comment_created` | `string` | Latest caller created comment of the `comment:` action | 211 | | `caller_action_state_changed` | `string` | Latest caller changed state of the `state:` action | 212 | | `caller_action_notify_sent` | `string` | Latest caller sent message of the `notify:` action | 213 | | `caller_action_do_error` | `string` | Latest caller error message when `do:` action failed | 214 | | `year` | `int` | Year of current time (UTC) | 215 | | `month` | `int` | Month of current time (UTC) | 216 | | `day` | `int` | Day of current time (UTC) | 217 | | `hour` | `int` | Hour of current time (UTC) | 218 | | `weekday` | `int` | Weekday of current time (UTC) (Sunday = 0, ...) | 219 | | `github.event_name` | `string` | Event name of GitHub Actions ( ex. `issues`, `pull_request` )| 220 | | `github.event` | `object` | Detailed data for each event of GitHub Actions (ex. `github.event.action`, `github.event.label.name` ) | 221 | | `env.` | `string` | The value of a specific environment variable | 222 | 223 | #### `tasks[*].env:` 224 | 225 | A map of environment environment variables in the scope of each task. 226 | 227 | #### `tasks[*].do:`, `tasks[*].ok:`, `tasks[*].ng:` 228 | 229 | A task has 3 actions ( called `do`, `ok` and `ng` ) with predetermined steps to be performed. 230 | 231 | 1. Perform `do:` action. 232 | 2. If the `do:` action succeeds, perform the `ok:` action. 233 | 2. If the `do:` action fails, perform the `ng:` action. 234 | 235 | ##### Available builtin environment variables 236 | 237 | | Environment variable | Description | 238 | | --- | --- | 239 | | `GHDAG_TASK_ID` | Task ID | 240 | | `GHDAG_CALLER_TASK_ID` | Task ID of the caller via the `next:` action | 241 | | `GHDAG_ACTION_RUN_STDOUT` | Latest STDOUT of the `run` action | 242 | | `GHDAG_ACTION_RUN_STDERR` | Latest STDERR of the `run` action | 243 | | `GHDAG_ACTION_LABELS_UPDATED` | Update result of the `labels:` action | 244 | | `GHDAG_ACTION_ASSIGNEES_UPDATED` | Update result of the `assgnees:` action | 245 | | `GHDAG_ACTION_REVIEWERS_UPDATED` | Update result of the `reviewers:` action | 246 | | `GHDAG_ACTION_COMMENT_CREATED` | Created comment of the `comment:` action | 247 | | `GHDAG_ACTION_STATE_CHANGED` | Changed state of the `state:` action | 248 | | `GHDAG_ACTION_NOTIFY_SENT` | Sent message of the `notify:` action | 249 | | `GHDAG_ACTION_DO_ERROR` | Error message when `do:` action failed | 250 | | `GHDAG_TASK_*` | [Variables available in the `if:` section](https://github.com/k1LoW/ghdag#available-variables). ( ex. `number` -> `GHDAG_TASK_NUMBER` ) | 251 | 252 | #### `tasks[*]..run:` 253 | 254 | Execute command using `sh -c`. 255 | 256 | **Example** 257 | 258 | ``` yaml 259 | do: 260 | run: echo 'execute command' 261 | ``` 262 | 263 | #### `tasks[*]..labels:` 264 | 265 | Update the labels of the target issue or pull request. 266 | 267 | **Example** 268 | 269 | ``` yaml 270 | do: 271 | labels: [question, bug] 272 | ``` 273 | 274 | #### `tasks[*]..assignees:` 275 | 276 | Update the assignees of the target issue or pull request. 277 | 278 | **Example** 279 | 280 | ``` yaml 281 | do: 282 | assignees: [alice, bob, charlie] 283 | env: 284 | GITHUB_ASSIGNEES_SAMPLE: 1 285 | ``` 286 | 287 | #### `tasks[*]..reviewers:` 288 | 289 | Update the reviewers for the target pull request. 290 | 291 | However, [Code owners](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners) has already been registered as a reviewer, so it is excluded. 292 | 293 | **Example** 294 | 295 | ``` yaml 296 | do: 297 | reviewers: [alice, bob, charlie] 298 | env: 299 | GITHUB_REVIEWERS_SAMPLE: 2 300 | ``` 301 | 302 | #### `tasks[*]..comment:` 303 | 304 | Create new comment to the target issue or pull request. 305 | 306 | **Example** 307 | 308 | ``` yaml 309 | do: 310 | labels: [question] 311 | ok: 312 | comment: Thank you for your question :+1: 313 | ``` 314 | 315 | #### `tasks[*]..state:` 316 | 317 | Change state the the target issue or pull request. 318 | 319 | **Example** 320 | 321 | ``` yaml 322 | if: hours_elapsed_since_updated > (24 * 30) 323 | do: 324 | state: close 325 | ``` 326 | 327 | ##### Changeable states 328 | 329 | | Target | Changeable states | 330 | | --- | --- | 331 | | Issue | `close` | 332 | | Pull request | `close` `merge` | 333 | 334 | #### `tasks[*]..notify:` 335 | 336 | Send notify message to Slack channel. 337 | 338 | **Example** 339 | 340 | ``` yaml 341 | ng: 342 | notify: error ${GHDAG_ACTION_OK_ERROR} 343 | env: 344 | SLACK_CHANNEL: workflow-alerts 345 | SLACK_MENTIONS: bob 346 | ``` 347 | 348 | ##### Required environment variables 349 | 350 | - ( `SLACK_API_TOKEN` and `SLACK_CHANNEL` ) or `SLACK_WEBHOOK_URL` 351 | 352 | #### `tasks[*]..next:` 353 | 354 | Call next tasks in the same session. 355 | 356 | **Example** 357 | 358 | ``` yaml 359 | tasks: 360 | - 361 | id: set-question-label 362 | if: 'is_issue && len(labels) == 0 && title endsWith "?"' 363 | do: 364 | labels: [question] 365 | ok: 366 | next [notify-slack, add-comment] 367 | - 368 | id: notify-slack 369 | do: 370 | notify: A question has been posted. 371 | - 372 | id: add-comment 373 | do: 374 | comment: Thank you your comment !!! 375 | ``` 376 | 377 | ## Environment variables for configuration 378 | 379 | | Environment variable | Description | Default on GitHub Actions | 380 | | --- | --- | --- | 381 | | `GITHUB_TOKEN` | A GitHub access token. | - | 382 | | `GITHUB_REPOSITORY` | The owner and repository name | `owner/repo` of the repository where GitHub Actions are running | 383 | | `GITHUB_API_URL` | The GitHub API URL | `https://api.github.com` | 384 | | `GITHUB_GRAPHQL_URL` | The GitHub GraphQL API URL | `https://api.github.com/graphql` | 385 | | `SLACK_API_TOKEN` | A Slack OAuth access token | - | 386 | | `SLACK_WEBHOOK_URL` | A Slack incoming webhook URL | - | 387 | | `SLACK_CHANNEL` | A Slack channel to be notified | - | 388 | | `GITHUB_ASSIGNEES_SAMPLE` | Number of users to randomly select from those listed in the `assignees:` action. | - | 389 | | `GITHUB_REVIEWERS_SAMPLE` | Number of users to randomly select from those listed in the `reviewers:` action. | - | 390 | | `GITHUB_COMMENT_MENTIONS` | Mentions to be given to comment | - | 391 | | `GITHUB_COMMENT_MENTIONS_SAMPLE` | Number of users to randomly select from those listed in `GITHUB_COMMENT_MENTIONS`. | - | 392 | | `SLACK_MENTIONS` | Mentions to be given to Slack message | - | 393 | | `SLACK_MENTIONS_SAMPLE` | Number of users to randomly select from those listed in `SLACK_MENTIONS`. | - | 394 | | `GHDAG_SAMPLE_WITH_SAME_SEED` | Sample using the same random seed as the previous action/task or not. | - | 395 | | `SLACK_USERNAME` | Custom `username` of slack message. Require `chat:write.customize` scope. | | 396 | | `SLACK_ICON_EMOJI` | Custom `icon_emoji` of slack message. Require `chat:write.customize` scope. | | 397 | | `SLACK_ICON_URL` | Custom `icon_url` of slack message. Require `chat:write.customize` scope. | | 398 | | `GITHUB_ASSIGNEES` | Additional Assignees to the list in the `assignees:` action | - | 399 | | `GITHUB_REVIEWERS` | Additional Reviewers to the list in the `reviewers:` action | - | 400 | | `GHDAG_ACTION_LABELS_BEHAVIOR` | Behavior of the `labels:` action ( `replace` (=default), `add`, `remove` ) | - | 401 | | `GHDAG_ACTION_ASSIGNEES_BEHAVIOR` | Behavior of the `assignees:` action ( `replace` (=default), `add`, `remove` ) | - | 402 | | `GHDAG_ACTION_COMMENT_MAX` | Maximum number of consecutive comments by the same login ( default: `5` ) | - | 403 | | `GHDAG_ACTION_RUN_RETRY_MAX` | Maximum number of retries for the `run:` action ( default: none ) | - | 404 | | `GHDAG_ACTION_RUN_RETRY_MIN_INTERVAL` | Minimum retry interval for the `run:` action ( default: `0 sec` ) | - | 405 | | `GHDAG_ACTION_RUN_RETRY_MAX_INTERVAL` | Maximum retry interval for the `run:` action ( default: `0 sec` ) | - | 406 | | `GHDAG_ACTION_RUN_RETRY_JITTER_FACTOR` | Jitter factor of retries for the `run:` action ( default: `0.05` ) | - | 407 | | `GHDAG_ACTION_RUN_RETRY_TIMEOUT` | Timeout for all retries execution time for the `run:` action ( default: `300 sec` ) | - | 408 | 409 | #### Required scope of `SLACK_API_TOKEN` 410 | 411 | - `channel:read` 412 | - `chat:write` 413 | - `chat:write.public` 414 | - `users:read` 415 | - `usergroups:read` 416 | - `chat:write.customize` ( optional ) 417 | 418 | ## Link the GitHub user or team name to the Slack user or team account name 419 | 420 | Provides the feature `linkedNames:` to link GitHub and Slack transparently even if they have different account names. 421 | 422 | **Example** 423 | 424 | Send a Slack message with a mention ( `@bob_marly` ) to the author ( `@bob` ) of the pull request. 425 | 426 | ``` yaml 427 | tasks: 428 | - 429 | id: notify-slack 430 | if: 'is_pull_request && is_approved && mergeable' 431 | do: 432 | notify: Your pull request is ready for merge ! 433 | env: 434 | SLACK_MENTIONS: ${GHDAG_TASK_AUTHOR} 435 | linkedNames: 436 | - 437 | github: bob 438 | slack: bob_marly 439 | ``` 440 | 441 | ## Use ghdag as the one-shot command on GitHub Actions 442 | 443 | `ghdag` can be used not only as a workflow engine, but also as a utility command in jobs on GitHub Actions. 444 | 445 | ``` console 446 | $ ghdag do --help 447 | do action. 448 | 449 | Usage: 450 | ghdag do [command] 451 | 452 | Available Commands: 453 | assignees update the assignees of the target issue or pull request 454 | comment create the comment of the target issue or pull request 455 | labels update the labels of the target issue or pull request 456 | notify send notify message to slack channel 457 | reviewers update the reviewers of the target issue or pull request 458 | run execute command using `sh -c` 459 | state change state of the target issue or pull request 460 | 461 | Flags: 462 | -h, --help help for do 463 | 464 | Use "ghdag do [command] --help" for more information about a command. 465 | $ ghdag if --help 466 | check condition. 467 | 468 | Usage: 469 | ghdag if [CONDITION] [flags] 470 | 471 | Flags: 472 | -h, --help help for if 473 | -n, --number int issue or pull request number 474 | ``` 475 | 476 | **Example** 477 | 478 | ``` yaml 479 | name: labels 480 | 481 | on: 482 | issues: 483 | types: [created] 484 | 485 | jobs: 486 | set-labels: 487 | name: Set labels 488 | runs-on: ubuntu-latest 489 | container: ghcr.io/k1low/ghdag:latest 490 | steps: 491 | - name: Set labels 492 | run: ghdag do labels question 493 | env: 494 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 495 | ``` 496 | 497 | ``` yaml 498 | name: build 499 | 500 | jobs: 501 | job-tests: 502 | 503 | # snip 504 | 505 | - name: Send notification to channel 506 | if: failure() 507 | run: ghdag do notify 'Test faild' 508 | env: 509 | SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} 510 | SLACK_CHANNEL: operations 511 | SLACK_MENTIONS: bob 512 | ``` 513 | 514 | ## Install 515 | 516 | **deb:** 517 | 518 | Use [dpkg-i-from-url](https://github.com/k1LoW/dpkg-i-from-url) 519 | 520 | ``` console 521 | $ export GHDAG_VERSION=X.X.X 522 | $ curl -L https://git.io/dpkg-i-from-url | bash -s -- https://github.com/k1LoW/ghdag/releases/download/v$GHDAG_VERSION/ghdag_$GHDAG_VERSION-1_amd64.deb 523 | ``` 524 | 525 | **RPM:** 526 | 527 | ``` console 528 | $ export GHDAG_VERSION=X.X.X 529 | $ yum install https://github.com/k1LoW/ghdag/releases/download/v$GHDAG_VERSION/ghdag_$GHDAG_VERSION-1_amd64.rpm 530 | ``` 531 | 532 | **apk:** 533 | 534 | Use [apk-add-from-url](https://github.com/k1LoW/apk-add-from-url) 535 | 536 | ``` console 537 | $ export GHDAG_VERSION=X.X.X 538 | $ curl -L https://git.io/apk-add-from-url | sh -s -- https://github.com/k1LoW/ghdag/releases/download/v$GHDAG_VERSION/ghdag_$GHDAG_VERSION-1_amd64.apk 539 | ``` 540 | 541 | **homebrew tap:** 542 | 543 | ```console 544 | $ brew install k1LoW/tap/ghdag 545 | ``` 546 | 547 | **manually:** 548 | 549 | Download binary from [releases page](https://github.com/k1LoW/ghdag/releases) 550 | 551 | **go get:** 552 | 553 | ```console 554 | $ go get github.com/k1LoW/ghdag 555 | ``` 556 | 557 | **docker:** 558 | 559 | ```console 560 | $ docker pull ghcr.io/k1low/ghdag:latest 561 | ``` 562 | -------------------------------------------------------------------------------- /gh/gh.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "sort" 15 | "strings" 16 | "time" 17 | 18 | "github.com/google/go-github/v33/github" 19 | "github.com/hairyhenderson/go-codeowners" 20 | "github.com/k1LoW/ghdag/erro" 21 | "github.com/k1LoW/ghdag/target" 22 | "github.com/rs/zerolog/log" 23 | "github.com/shurcooL/githubv4" 24 | "golang.org/x/oauth2" 25 | ) 26 | 27 | const limit = 100 28 | 29 | type GhClient interface { 30 | FetchTargets(ctx context.Context) (target.Targets, error) 31 | FetchTarget(ctx context.Context, n int) (*target.Target, error) 32 | SetLabels(ctx context.Context, n int, labels []string) error 33 | SetAssignees(ctx context.Context, n int, assignees []string) error 34 | SetReviewers(ctx context.Context, n int, reviewers []string) error 35 | AddComment(ctx context.Context, n int, comment string) error 36 | CloseIssue(ctx context.Context, n int) error 37 | MergePullRequest(ctx context.Context, n int) error 38 | ResolveUsers(ctx context.Context, in []string) ([]string, error) 39 | } 40 | 41 | type Client struct { 42 | v3 *github.Client 43 | v4 *githubv4.Client 44 | owner string 45 | repo string 46 | } 47 | 48 | // NewClient return Client 49 | func NewClient() (*Client, error) { 50 | ctx := context.Background() 51 | 52 | // GITHUB_TOKEN 53 | token := os.Getenv("GITHUB_TOKEN") 54 | if token == "" { 55 | return nil, fmt.Errorf("env %s is not set", "GITHUB_TOKEN") 56 | } 57 | 58 | // REST API Client 59 | v3c := github.NewClient(httpClient(token)) 60 | if v3ep := os.Getenv("GITHUB_API_URL"); v3ep != "" { 61 | baseEndpoint, err := url.Parse(v3ep) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if !strings.HasSuffix(baseEndpoint.Path, "/") { 66 | baseEndpoint.Path += "/" 67 | } 68 | v3c.BaseURL = baseEndpoint 69 | } 70 | 71 | // GraphQL API Client 72 | src := oauth2.StaticTokenSource( 73 | &oauth2.Token{AccessToken: token}, 74 | ) 75 | v4hc := oauth2.NewClient(ctx, src) 76 | v4ep := os.Getenv("GITHUB_GRAPHQL_URL") 77 | if v4ep == "" { 78 | v4ep = "https://api.github.com/graphql" 79 | } 80 | v4c := githubv4.NewEnterpriseClient(v4ep, v4hc) 81 | 82 | ownerrepo := os.Getenv("GITHUB_REPOSITORY") 83 | if ownerrepo == "" { 84 | return nil, fmt.Errorf("env %s is not set", "GITHUB_REPOSITORY") 85 | } 86 | splitted := strings.Split(ownerrepo, "/") 87 | 88 | owner := splitted[0] 89 | repo := splitted[1] 90 | 91 | _, res, err := v3c.Repositories.Get(ctx, owner, repo) 92 | scopes := strings.Split(res.Header.Get("X-OAuth-Scopes"), ", ") 93 | log.Debug().Msg(fmt.Sprintf("the scopes your token has authorized: '%s'", strings.Join(scopes, "', '"))) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return &Client{ 99 | v3: v3c, 100 | v4: v4c, 101 | owner: owner, 102 | repo: repo, 103 | }, nil 104 | } 105 | 106 | type issueNode struct { 107 | Author struct { 108 | Login githubv4.String 109 | } 110 | Number githubv4.Int 111 | State githubv4.String 112 | Title githubv4.String 113 | Body githubv4.String 114 | URL githubv4.String 115 | CreatedAt githubv4.DateTime 116 | UpdatedAt githubv4.DateTime 117 | Labels struct { 118 | Nodes []struct { 119 | Name githubv4.String 120 | } 121 | } `graphql:"labels(first: 100)"` 122 | Assignees struct { 123 | Nodes []struct { 124 | Login githubv4.String 125 | } 126 | } `graphql:"assignees(first: 100)"` 127 | Comments struct { 128 | Nodes []struct { 129 | Author struct { 130 | Login githubv4.String 131 | } 132 | Body githubv4.String 133 | CreatedAt githubv4.DateTime 134 | } 135 | PageInfo struct { 136 | HasNextPage bool 137 | } 138 | } `graphql:"comments(first: $limit, orderBy: {direction: DESC, field: UPDATED_AT})"` 139 | } 140 | 141 | type pullRequestNode struct { 142 | Author struct { 143 | Login githubv4.String 144 | } 145 | HeadRefName githubv4.String 146 | Number githubv4.Int 147 | State githubv4.String 148 | Title githubv4.String 149 | Body githubv4.String 150 | URL githubv4.String 151 | IsDraft githubv4.Boolean 152 | ChangedFiles githubv4.Int 153 | Mergeable githubv4.MergeableState 154 | ReviewDecision githubv4.PullRequestReviewDecision 155 | ReviewRequests struct { 156 | Nodes []struct { 157 | AsCodeOwner githubv4.Boolean 158 | RequestedReviewer struct { 159 | User struct { 160 | Login githubv4.String 161 | } `graphql:"... on User"` 162 | Team struct { 163 | Organization struct { 164 | Login githubv4.String 165 | } 166 | Slug githubv4.String 167 | } `graphql:"... on Team"` 168 | } 169 | } 170 | } `graphql:"reviewRequests(first: 100)"` 171 | LatestReviews struct { 172 | Nodes []struct { 173 | Author struct { 174 | Login githubv4.String 175 | } 176 | State githubv4.PullRequestReviewState 177 | } 178 | } `graphql:"latestReviews(first: 100)"` 179 | CreatedAt githubv4.DateTime 180 | UpdatedAt githubv4.DateTime 181 | Labels struct { 182 | Nodes []struct { 183 | Name githubv4.String 184 | } 185 | } `graphql:"labels(first: 100)"` 186 | Assignees struct { 187 | Nodes []struct { 188 | Login githubv4.String 189 | } 190 | } `graphql:"assignees(first: 100)"` 191 | Comments struct { 192 | Nodes []struct { 193 | Author struct { 194 | Login githubv4.String 195 | } 196 | Body githubv4.String 197 | CreatedAt githubv4.DateTime 198 | } 199 | PageInfo struct { 200 | HasNextPage bool 201 | } 202 | } `graphql:"comments(first: $limit, orderBy: {direction: DESC, field: UPDATED_AT})"` 203 | } 204 | 205 | type pullRequestFilesNode struct { 206 | Files struct { 207 | Nodes []struct { 208 | Path githubv4.String 209 | } 210 | PageInfo struct { 211 | HasNextPage bool 212 | EndCursor githubv4.String 213 | } 214 | } `graphql:"files(first: $limit, after: $cursor)"` 215 | } 216 | 217 | func (c *Client) FetchTargets(ctx context.Context) (target.Targets, error) { 218 | targets := target.Targets{} 219 | 220 | var q struct { 221 | Viewer struct { 222 | Login githubv4.String 223 | } `graphql:"viewer"` 224 | Repogitory struct { 225 | Issues struct { 226 | Nodes []issueNode 227 | PageInfo struct { 228 | HasNextPage bool 229 | } 230 | } `graphql:"issues(first: $limit, states: OPEN, orderBy: {direction: DESC, field: CREATED_AT})"` 231 | PullRequests struct { 232 | Nodes []pullRequestNode 233 | PageInfo struct { 234 | HasNextPage bool 235 | } 236 | } `graphql:"pullRequests(first: $limit, states: OPEN, orderBy: {direction: DESC, field: CREATED_AT})"` 237 | } `graphql:"repository(owner: $owner, name: $repo)"` 238 | } 239 | variables := map[string]interface{}{ 240 | "owner": githubv4.String(c.owner), 241 | "repo": githubv4.String(c.repo), 242 | "limit": githubv4.Int(limit), 243 | } 244 | 245 | if err := c.v4.Query(ctx, &q, variables); err != nil { 246 | return nil, err 247 | } 248 | 249 | if q.Repogitory.Issues.PageInfo.HasNextPage { 250 | return nil, fmt.Errorf("too many opened issues (limit: %d)", limit) 251 | } 252 | 253 | if q.Repogitory.PullRequests.PageInfo.HasNextPage { 254 | return nil, fmt.Errorf("too many opened pull requests (limit: %d)", limit) 255 | } 256 | 257 | now := time.Now() 258 | login := string(q.Viewer.Login) 259 | 260 | for _, i := range q.Repogitory.Issues.Nodes { 261 | t, err := buildTargetFromIssue(login, i, now) 262 | if err != nil { 263 | return nil, err 264 | } 265 | targets[t.Number] = t 266 | } 267 | 268 | for _, p := range q.Repogitory.PullRequests.Nodes { 269 | if bool(p.IsDraft) { 270 | // Skip draft pull request 271 | continue 272 | } 273 | t, err := c.buildTargetFromPullRequest(ctx, login, p, now) 274 | if err != nil { 275 | return nil, err 276 | } 277 | targets[t.Number] = t 278 | } 279 | 280 | return targets, nil 281 | } 282 | 283 | func (c *Client) FetchTarget(ctx context.Context, n int) (*target.Target, error) { 284 | var q struct { 285 | Viewer struct { 286 | Login githubv4.String 287 | } `graphql:"viewer"` 288 | Repogitory struct { 289 | IssueOrPullRequest struct { 290 | Issue issueNode `graphql:"... on Issue"` 291 | PullRequest pullRequestNode `graphql:"... on PullRequest"` 292 | } `graphql:"issueOrPullRequest(number: $number)"` 293 | } `graphql:"repository(owner: $owner, name: $repo)"` 294 | } 295 | variables := map[string]interface{}{ 296 | "owner": githubv4.String(c.owner), 297 | "repo": githubv4.String(c.repo), 298 | "number": githubv4.Int(n), 299 | "limit": githubv4.Int(limit), 300 | } 301 | 302 | if err := c.v4.Query(ctx, &q, variables); err != nil { 303 | return nil, err 304 | } 305 | 306 | now := time.Now() 307 | login := string(q.Viewer.Login) 308 | 309 | if strings.Contains(string(q.Repogitory.IssueOrPullRequest.Issue.URL), "/issues/") { 310 | // Issue 311 | i := q.Repogitory.IssueOrPullRequest.Issue 312 | state := strings.ToLower(string(i.State)) 313 | if state != "open" { 314 | return nil, erro.NewNotOpenError(fmt.Errorf("issue #%d is %s", int(i.Number), state)) 315 | } 316 | return buildTargetFromIssue(login, i, now) 317 | } else { 318 | // Pull request 319 | p := q.Repogitory.IssueOrPullRequest.PullRequest 320 | state := strings.ToLower(string(p.State)) 321 | if state != "open" { 322 | return nil, erro.NewNotOpenError(fmt.Errorf("pull request #%d is %s", int(p.Number), state)) 323 | } 324 | if bool(p.IsDraft) { 325 | return nil, erro.NewNotOpenError(fmt.Errorf("pull request #%d is draft", int(p.Number))) 326 | } 327 | return c.buildTargetFromPullRequest(ctx, login, p, now) 328 | } 329 | } 330 | 331 | func (c *Client) SetLabels(ctx context.Context, n int, labels []string) error { 332 | _, _, err := c.v3.Issues.Edit(ctx, c.owner, c.repo, n, &github.IssueRequest{ 333 | Labels: &labels, 334 | }) 335 | return err 336 | } 337 | 338 | func (c *Client) SetAssignees(ctx context.Context, n int, assignees []string) error { 339 | if _, _, err := c.v3.Issues.Edit(ctx, c.owner, c.repo, n, &github.IssueRequest{ 340 | Assignees: &assignees, 341 | }); err != nil { 342 | return err 343 | } 344 | return nil 345 | } 346 | 347 | func (c *Client) SetReviewers(ctx context.Context, n int, reviewers []string) error { 348 | ru := map[string]struct{}{} 349 | rt := map[string]struct{}{} 350 | for _, r := range reviewers { 351 | trimed := strings.TrimPrefix(r, "@") 352 | if strings.Contains(trimed, "/") { 353 | splitted := strings.Split(trimed, "/") 354 | rt[splitted[1]] = struct{}{} 355 | continue 356 | } 357 | ru[trimed] = struct{}{} 358 | } 359 | current, _, err := c.v3.PullRequests.ListReviewers(ctx, c.owner, c.repo, n, &github.ListOptions{}) 360 | if err != nil { 361 | return err 362 | } 363 | du := []string{} 364 | dt := []string{} 365 | for _, u := range current.Users { 366 | if _, ok := ru[u.GetLogin()]; ok { 367 | delete(ru, u.GetLogin()) 368 | continue 369 | } 370 | du = append(du, u.GetLogin()) 371 | } 372 | for _, t := range current.Teams { 373 | if _, ok := rt[t.GetSlug()]; ok { 374 | delete(rt, t.GetSlug()) 375 | continue 376 | } 377 | dt = append(dt, t.GetSlug()) 378 | } 379 | if len(du) > 0 || len(dt) > 0 { 380 | if len(du) == 0 { 381 | du = append(du, "ghdag-dummy") 382 | } 383 | if _, err := c.v3.PullRequests.RemoveReviewers(ctx, c.owner, c.repo, n, github.ReviewersRequest{ 384 | Reviewers: du, 385 | TeamReviewers: dt, 386 | }); err != nil { 387 | return err 388 | } 389 | } 390 | au := []string{} 391 | at := []string{} 392 | for k := range ru { 393 | au = append(au, k) 394 | } 395 | for k := range rt { 396 | at = append(at, k) 397 | } 398 | if _, _, err := c.v3.PullRequests.RequestReviewers(ctx, c.owner, c.repo, n, github.ReviewersRequest{ 399 | Reviewers: au, 400 | TeamReviewers: at, 401 | }); err != nil { 402 | return err 403 | } 404 | return nil 405 | } 406 | 407 | func (c *Client) AddComment(ctx context.Context, n int, comment string) error { 408 | _, _, err := c.v3.Issues.CreateComment(ctx, c.owner, c.repo, n, &github.IssueComment{ 409 | Body: &comment, 410 | }) 411 | return err 412 | } 413 | 414 | func (c *Client) CloseIssue(ctx context.Context, n int) error { 415 | state := "closed" 416 | _, _, err := c.v3.Issues.Edit(ctx, c.owner, c.repo, n, &github.IssueRequest{ 417 | State: &state, 418 | }) 419 | return err 420 | } 421 | 422 | func (c *Client) MergePullRequest(ctx context.Context, n int) error { 423 | _, _, err := c.v3.PullRequests.Merge(ctx, c.owner, c.repo, n, "", &github.PullRequestOptions{}) 424 | return err 425 | } 426 | 427 | func (c *Client) ResolveUsers(ctx context.Context, in []string) ([]string, error) { 428 | res := []string{} 429 | for _, inu := range in { 430 | trimed := strings.TrimPrefix(inu, "@") 431 | if !strings.Contains(trimed, "/") { 432 | res = append(res, trimed) 433 | continue 434 | } 435 | splitted := strings.Split(trimed, "/") 436 | org := splitted[0] 437 | slug := splitted[1] 438 | opts := &github.TeamListTeamMembersOptions{} 439 | users, _, err := c.v3.Teams.ListTeamMembersBySlug(ctx, org, slug, opts) 440 | if err != nil { 441 | return nil, err 442 | } 443 | for _, u := range users { 444 | res = append(res, *u.Login) 445 | } 446 | } 447 | return unique(res), nil 448 | } 449 | 450 | type roundTripper struct { 451 | transport *http.Transport 452 | accessToken string 453 | } 454 | 455 | func (rt roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { 456 | r.Header.Set("Authorization", fmt.Sprintf("token %s", rt.accessToken)) 457 | return rt.transport.RoundTrip(r) 458 | } 459 | 460 | func buildTargetFromIssue(login string, i issueNode, now time.Time) (*target.Target, error) { 461 | n := int(i.Number) 462 | 463 | if i.Comments.PageInfo.HasNextPage { 464 | return nil, fmt.Errorf("too many issue comments (number: %d, limit: %d)", n, limit) 465 | } 466 | latestComment := struct { 467 | Author struct { 468 | Login githubv4.String 469 | } 470 | Body githubv4.String 471 | CreatedAt githubv4.DateTime 472 | }{} 473 | sort.Slice(i.Comments.Nodes, func(a, b int) bool { 474 | // CreatedAt DESC 475 | return (i.Comments.Nodes[a].CreatedAt.Unix() > i.Comments.Nodes[b].CreatedAt.Unix()) 476 | }) 477 | if len(i.Comments.Nodes) > 0 { 478 | latestComment = i.Comments.Nodes[0] 479 | } 480 | numComments := 0 481 | for _, c := range i.Comments.Nodes { 482 | if string(c.Author.Login) != login { 483 | break 484 | } 485 | numComments++ 486 | } 487 | 488 | labels := []string{} 489 | for _, l := range i.Labels.Nodes { 490 | labels = append(labels, string(l.Name)) 491 | } 492 | assignees := []string{} 493 | for _, a := range i.Assignees.Nodes { 494 | assignees = append(assignees, string(a.Login)) 495 | } 496 | 497 | return &target.Target{ 498 | Number: n, 499 | State: strings.ToLower(string(i.State)), 500 | Title: string(i.Title), 501 | Body: string(i.Body), 502 | URL: string(i.URL), 503 | Author: string(i.Author.Login), 504 | Labels: labels, 505 | Assignees: assignees, 506 | IsIssue: true, 507 | IsPullRequest: false, 508 | HoursElapsedSinceCreated: int(now.Sub(i.CreatedAt.Time).Hours()), 509 | HoursElapsedSinceUpdated: int(now.Sub(i.UpdatedAt.Time).Hours()), 510 | NumberOfComments: len(i.Comments.Nodes), 511 | LatestCommentAuthor: string(latestComment.Author.Login), 512 | LatestCommentBody: string(latestComment.Body), 513 | NumberOfConsecutiveComments: numComments, 514 | Login: login, 515 | }, nil 516 | } 517 | 518 | func (c *Client) buildTargetFromPullRequest(ctx context.Context, login string, p pullRequestNode, now time.Time) (*target.Target, error) { 519 | n := int(p.Number) 520 | 521 | if p.Comments.PageInfo.HasNextPage { 522 | return nil, fmt.Errorf("too many pull request comments (number: %d, limit: %d)", n, limit) 523 | } 524 | latestComment := struct { 525 | Author struct { 526 | Login githubv4.String 527 | } 528 | Body githubv4.String 529 | CreatedAt githubv4.DateTime 530 | }{} 531 | sort.Slice(p.Comments.Nodes, func(a, b int) bool { 532 | // CreatedAt DESC 533 | return (p.Comments.Nodes[a].CreatedAt.Unix() > p.Comments.Nodes[b].CreatedAt.Unix()) 534 | }) 535 | if len(p.Comments.Nodes) > 0 { 536 | latestComment = p.Comments.Nodes[0] 537 | } 538 | numComments := 0 539 | for _, c := range p.Comments.Nodes { 540 | if string(c.Author.Login) != login { 541 | break 542 | } 543 | numComments++ 544 | } 545 | 546 | isApproved := false 547 | isReviewRequired := false 548 | isChangeRequested := false 549 | switch p.ReviewDecision { 550 | case githubv4.PullRequestReviewDecisionApproved: 551 | isApproved = true 552 | case githubv4.PullRequestReviewDecisionReviewRequired: 553 | isReviewRequired = true 554 | case githubv4.PullRequestReviewDecisionChangesRequested: 555 | isChangeRequested = true 556 | } 557 | mergeable := false 558 | if p.Mergeable == githubv4.MergeableStateMergeable { 559 | mergeable = true 560 | } 561 | 562 | labels := []string{} 563 | for _, l := range p.Labels.Nodes { 564 | labels = append(labels, string(l.Name)) 565 | } 566 | assignees := []string{} 567 | for _, a := range p.Assignees.Nodes { 568 | assignees = append(assignees, string(a.Login)) 569 | } 570 | reviewers := []string{} 571 | codeOwners := []string{} 572 | codeOwnersWhoApproved := []string{} 573 | for _, r := range p.ReviewRequests.Nodes { 574 | var k string 575 | if r.RequestedReviewer.User.Login != "" { 576 | k = string(r.RequestedReviewer.User.Login) 577 | } 578 | if r.RequestedReviewer.Team.Slug != "" { 579 | k = fmt.Sprintf("%s/%s", string(r.RequestedReviewer.Team.Organization.Login), string(r.RequestedReviewer.Team.Slug)) 580 | } 581 | reviewers = append(reviewers, k) 582 | if bool(r.AsCodeOwner) { 583 | codeOwners = append(codeOwners, k) 584 | } 585 | } 586 | reviewersWhoApproved := []string{} 587 | for _, r := range p.LatestReviews.Nodes { 588 | u := string(r.Author.Login) 589 | reviewers = append(reviewers, u) 590 | if r.State != githubv4.PullRequestReviewStateApproved { 591 | continue 592 | } 593 | reviewersWhoApproved = append(reviewersWhoApproved, u) 594 | } 595 | reviewers = unique(reviewers) 596 | 597 | if len(reviewersWhoApproved) > 0 { 598 | // re-calc code_owners* 599 | codeOwners = []string{} 600 | // calcedCodeOwners contains users that exist in the CODEOWNERS file but do not actually exist or do not have permissions. 601 | calcedCodeOwners, err := c.getCodeOwners(ctx, p) 602 | if err != nil { 603 | return nil, err 604 | } 605 | for _, u := range reviewersWhoApproved { 606 | if contains(calcedCodeOwners, u) { 607 | codeOwnersWhoApproved = append(codeOwnersWhoApproved, u) 608 | } 609 | } 610 | for _, u := range reviewers { 611 | if contains(calcedCodeOwners, u) { 612 | codeOwners = append(codeOwners, u) 613 | } 614 | } 615 | } 616 | 617 | return &target.Target{ 618 | Number: n, 619 | State: string(p.State), 620 | Title: string(p.Title), 621 | Body: string(p.Body), 622 | URL: string(p.URL), 623 | Author: string(p.Author.Login), 624 | Labels: labels, 625 | Assignees: assignees, 626 | Reviewers: reviewers, 627 | CodeOwners: codeOwners, 628 | ReviewersWhoApproved: reviewersWhoApproved, 629 | CodeOwnersWhoApproved: codeOwnersWhoApproved, 630 | IsIssue: false, 631 | IsPullRequest: true, 632 | IsApproved: isApproved, 633 | IsReviewRequired: isReviewRequired, 634 | IsChangeRequested: isChangeRequested, 635 | Mergeable: mergeable, 636 | ChangedFiles: int(p.ChangedFiles), 637 | HoursElapsedSinceCreated: int(now.Sub(p.CreatedAt.Time).Hours()), 638 | HoursElapsedSinceUpdated: int(now.Sub(p.UpdatedAt.Time).Hours()), 639 | NumberOfComments: len(p.Comments.Nodes), 640 | LatestCommentAuthor: string(latestComment.Author.Login), 641 | LatestCommentBody: string(latestComment.Body), 642 | NumberOfConsecutiveComments: numComments, 643 | Login: login, 644 | }, nil 645 | } 646 | 647 | func (c *Client) getCodeOwners(ctx context.Context, p pullRequestNode) ([]string, error) { 648 | // Get CODEOWNERS file 649 | var cc string 650 | for _, path := range []string{".github/CODEOWNERS", "docs/CODEOWNERS"} { 651 | f, _, _, err := c.v3.Repositories.GetContents(ctx, c.owner, c.repo, path, &github.RepositoryContentGetOptions{ 652 | Ref: string(p.HeadRefName), 653 | }) 654 | if err != nil { 655 | continue 656 | } 657 | 658 | switch *f.Encoding { 659 | case "base64": 660 | if f.Content == nil { 661 | break 662 | } 663 | c, err := base64.StdEncoding.DecodeString(*f.Content) 664 | if err != nil { 665 | break 666 | } 667 | cc = string(c) 668 | case "": 669 | if f.Content == nil { 670 | cc = "" 671 | } else { 672 | cc = *f.Content 673 | } 674 | default: 675 | break 676 | } 677 | break 678 | } 679 | 680 | if cc == "" { 681 | return []string{}, nil 682 | } 683 | 684 | d, err := codeowners.FromReader(strings.NewReader(cc), ".") 685 | if err != nil { 686 | return nil, err 687 | } 688 | 689 | var cursor string 690 | co := []string{} 691 | 692 | var q struct { 693 | Repogitory struct { 694 | PullRequest pullRequestFilesNode `graphql:"pullRequest(number: $number)"` 695 | } `graphql:"repository(owner: $owner, name: $repo)"` 696 | } 697 | 698 | for { 699 | variables := map[string]interface{}{ 700 | "owner": githubv4.String(c.owner), 701 | "repo": githubv4.String(c.repo), 702 | "number": p.Number, 703 | "limit": githubv4.Int(limit), 704 | "cursor": githubv4.String(cursor), 705 | } 706 | if err := c.v4.Query(ctx, &q, variables); err != nil { 707 | return nil, err 708 | } 709 | for _, f := range q.Repogitory.PullRequest.Files.Nodes { 710 | co = append(co, d.Owners(string(f.Path))...) 711 | } 712 | if !q.Repogitory.PullRequest.Files.PageInfo.HasNextPage { 713 | break 714 | } 715 | cursor = string(q.Repogitory.PullRequest.Files.PageInfo.EndCursor) 716 | } 717 | 718 | codeOwners := []string{} 719 | for _, o := range unique(co) { 720 | codeOwners = append(codeOwners, strings.TrimPrefix(o, "@")) 721 | } 722 | return codeOwners, nil 723 | } 724 | 725 | type GitHubEvent struct { 726 | Name string 727 | Number int 728 | State string 729 | Payload interface{} 730 | } 731 | 732 | func DecodeGitHubEvent() (*GitHubEvent, error) { 733 | i := &GitHubEvent{} 734 | n := os.Getenv("GITHUB_EVENT_NAME") 735 | if n == "" { 736 | return i, fmt.Errorf("env %s is not set.", "GITHUB_EVENT_NAME") 737 | } 738 | i.Name = n 739 | p := os.Getenv("GITHUB_EVENT_PATH") 740 | if p == "" { 741 | return i, fmt.Errorf("env %s is not set.", "GITHUB_EVENT_PATH") 742 | } 743 | b, err := ioutil.ReadFile(filepath.Clean(p)) 744 | if err != nil { 745 | return i, err 746 | } 747 | s := struct { 748 | PullRequest struct { 749 | Number int `json:"number,omitempty"` 750 | State string `json:"state,omitempty"` 751 | } `json:"pull_request,omitempty"` 752 | Issue struct { 753 | Number int `json:"number,omitempty"` 754 | State string `json:"state,omitempty"` 755 | } `json:"issue,omitempty"` 756 | }{} 757 | if err := json.Unmarshal(b, &s); err != nil { 758 | return i, err 759 | } 760 | switch { 761 | case s.PullRequest.Number > 0: 762 | i.Number = s.PullRequest.Number 763 | i.State = s.PullRequest.State 764 | case s.Issue.Number > 0: 765 | i.Number = s.Issue.Number 766 | i.State = s.Issue.State 767 | } 768 | 769 | var payload interface{} 770 | 771 | if err := json.Unmarshal(b, &payload); err != nil { 772 | return i, err 773 | } 774 | 775 | i.Payload = payload 776 | 777 | return i, nil 778 | } 779 | 780 | func httpClient(token string) *http.Client { 781 | t := &http.Transport{ 782 | Dial: (&net.Dialer{ 783 | Timeout: 5 * time.Second, 784 | }).Dial, 785 | TLSHandshakeTimeout: 5 * time.Second, 786 | } 787 | rt := roundTripper{ 788 | transport: t, 789 | accessToken: token, 790 | } 791 | return &http.Client{ 792 | Timeout: time.Second * 10, 793 | Transport: rt, 794 | } 795 | } 796 | 797 | func contains(s []string, e string) bool { 798 | for _, v := range s { 799 | if e == v { 800 | return true 801 | } 802 | } 803 | return false 804 | } 805 | 806 | func unique(in []string) []string { 807 | u := []string{} 808 | m := map[string]struct{}{} 809 | for _, s := range in { 810 | if _, ok := m[s]; ok { 811 | continue 812 | } 813 | u = append(u, s) 814 | m[s] = struct{}{} 815 | } 816 | return u 817 | } 818 | --------------------------------------------------------------------------------