diff --git a/ent/callback/callback.go b/ent/callback/callback.go index c30ec5f0..b1273d42 100644 --- a/ent/callback/callback.go +++ b/ent/callback/callback.go @@ -73,6 +73,8 @@ type Type string const ( TypeDeploy Type = "deploy" TypeRollback Type = "rollback" + TypeLock Type = "lock" + TypeUnlock Type = "unlock" ) func (_type Type) String() string { @@ -82,7 +84,7 @@ func (_type Type) String() string { // TypeValidator is a validator for the "type" field enum values. It is called by the builders before save. func TypeValidator(_type Type) error { switch _type { - case TypeDeploy, TypeRollback: + case TypeDeploy, TypeRollback, TypeLock, TypeUnlock: return nil default: return fmt.Errorf("callback: invalid enum value for type field: %q", _type) diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index 237856b8..d9df9622 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -41,7 +41,7 @@ var ( CallbacksColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt, Increment: true}, {Name: "hash", Type: field.TypeString, Unique: true}, - {Name: "type", Type: field.TypeEnum, Enums: []string{"deploy", "rollback"}}, + {Name: "type", Type: field.TypeEnum, Enums: []string{"deploy", "rollback", "lock", "unlock"}}, {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "repo_id", Type: field.TypeString, Nullable: true}, diff --git a/ent/schema/callback.go b/ent/schema/callback.go index e5a0c6a5..e80017c9 100644 --- a/ent/schema/callback.go +++ b/ent/schema/callback.go @@ -25,6 +25,8 @@ func (Callback) Fields() []ent.Field { Values( "deploy", "rollback", + "lock", + "unlock", ), field.Time("created_at"). Default(time.Now), diff --git a/go.mod b/go.mod index 4673f65f..09b26f81 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,5 @@ require ( go.uber.org/zap v1.13.0 golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c - gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c ) diff --git a/go.sum b/go.sum index 244ba3d3..c1dd3122 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,6 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -334,8 +332,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nleeper/goment v1.4.2 h1:r4c8KkCrsBJUnVi/IJ5HEqev5QY8aCWOXQtu+eYXtnI= github.com/nleeper/goment v1.4.2/go.mod h1:zDl5bAyDhqxwQKAvkSXMRLOdCowrdZz53ofRJc4VhTo= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= @@ -739,8 +735,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= -gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= -gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= diff --git a/internal/interactor/interface.go b/internal/interactor/interface.go index bc79e7bd..75ec1d12 100644 --- a/internal/interactor/interface.go +++ b/internal/interactor/interface.go @@ -68,6 +68,7 @@ type ( DeleteApproval(ctx context.Context, a *ent.Approval) error ListLocksOfRepo(ctx context.Context, r *ent.Repo) ([]*ent.Lock, error) + FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) HasLockOfRepoForEnv(ctx context.Context, r *ent.Repo, env string) (bool, error) FindLockByID(ctx context.Context, id int) (*ent.Lock, error) CreateLock(ctx context.Context, l *ent.Lock) (*ent.Lock, error) diff --git a/internal/interactor/mock/pkg.go b/internal/interactor/mock/pkg.go index c0dd1726..aa341e1d 100644 --- a/internal/interactor/mock/pkg.go +++ b/internal/interactor/mock/pkg.go @@ -424,6 +424,21 @@ func (mr *MockStoreMockRecorder) FindLockByID(ctx, id interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockByID", reflect.TypeOf((*MockStore)(nil).FindLockByID), ctx, id) } +// FindLockOfRepoByEnv mocks base method. +func (m *MockStore) FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLockOfRepoByEnv", ctx, r, env) + ret0, _ := ret[0].(*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLockOfRepoByEnv indicates an expected call of FindLockOfRepoByEnv. +func (mr *MockStoreMockRecorder) FindLockOfRepoByEnv(ctx, r, env interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockOfRepoByEnv", reflect.TypeOf((*MockStore)(nil).FindLockOfRepoByEnv), ctx, r, env) +} + // FindPermOfRepo mocks base method. func (m *MockStore) FindPermOfRepo(ctx context.Context, r *ent.Repo, u *ent.User) (*ent.Perm, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/store/lock.go b/internal/pkg/store/lock.go index 9ec85efe..5df9e92a 100644 --- a/internal/pkg/store/lock.go +++ b/internal/pkg/store/lock.go @@ -16,6 +16,20 @@ func (s *Store) ListLocksOfRepo(ctx context.Context, r *ent.Repo) ([]*ent.Lock, All(ctx) } +func (s *Store) FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) { + return s.c.Lock. + Query(). + Where( + lock.And( + lock.RepoID(r.ID), + lock.EnvEQ(env), + ), + ). + WithUser(). + WithRepo(). + Only(ctx) +} + func (s *Store) HasLockOfRepoForEnv(ctx context.Context, r *ent.Repo, env string) (bool, error) { cnt, err := s.c.Lock. Query(). diff --git a/internal/server/api/v1/repos/lock_test.go b/internal/server/api/v1/repos/lock_test.go index 1407975d..e9db385d 100644 --- a/internal/server/api/v1/repos/lock_test.go +++ b/internal/server/api/v1/repos/lock_test.go @@ -101,7 +101,7 @@ func TestRepo_CreateLock(t *testing.T) { t.Log("Get the lock with edges") m. EXPECT(). - FindLockByID(gomock.Any(), gomock.AssignableToTypeOf(&ent.Lock{})). + FindLockByID(gomock.Any(), 1). Return(&ent.Lock{ID: 1}, nil) r := NewRepo(RepoConfig{}, m) diff --git a/internal/server/api/v1/repos/mock/interactor.go b/internal/server/api/v1/repos/mock/interactor.go index f1e5fd36..fdab8b65 100644 --- a/internal/server/api/v1/repos/mock/interactor.go +++ b/internal/server/api/v1/repos/mock/interactor.go @@ -259,6 +259,21 @@ func (mr *MockInteractorMockRecorder) FindLockByID(ctx, id interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockByID", reflect.TypeOf((*MockInteractor)(nil).FindLockByID), ctx, id) } +// FindLockOfRepoByEnv mocks base method. +func (m *MockInteractor) FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLockOfRepoByEnv", ctx, r, env) + ret0, _ := ret[0].(*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLockOfRepoByEnv indicates an expected call of FindLockOfRepoByEnv. +func (mr *MockInteractorMockRecorder) FindLockOfRepoByEnv(ctx, r, env interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockOfRepoByEnv", reflect.TypeOf((*MockInteractor)(nil).FindLockOfRepoByEnv), ctx, r, env) +} + // FindPermOfRepo mocks base method. func (m *MockInteractor) FindPermOfRepo(ctx context.Context, r *ent.Repo, u *ent.User) (*ent.Perm, error) { m.ctrl.T.Helper() diff --git a/internal/server/slack/deploy.go b/internal/server/slack/deploy.go index 6442f7d2..5345022b 100644 --- a/internal/server/slack/deploy.go +++ b/internal/server/slack/deploy.go @@ -59,7 +59,7 @@ func (s *Slack) handleDeployCmd(c *gin.Context) { bv, _ := c.Get(KeyChatUser) cu := bv.(*ent.ChatUser) - s.log.Debug("Process deploy command.", zap.String("command", cmd.Text)) + s.log.Debug("Processing deploy command.", zap.String("command", cmd.Text)) ns, n := parseCmd(cmd.Text) r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) @@ -121,15 +121,6 @@ func parseCmd(cmd string) (string, string) { return nn[0], nn[1] } -func parseFullName(fullname string) (string, string, error) { - namespaceName := strings.Split(fullname, "/") - if len(namespaceName) != 2 { - return "", "", fmt.Errorf("It is a invalid formatted command.") - } - - return namespaceName[0], namespaceName[1], nil -} - func buildDeployView(callbackID string, c *vo.Config, perms []*ent.Perm) slack.ModalViewRequest { envs := []*slack.OptionBlockObject{} for _, env := range c.Envs { @@ -279,7 +270,7 @@ func (s *Slack) interactDeploy(c *gin.Context) { } if locked, err := s.i.HasLockOfRepoForEnv(ctx, cb.Edges.Repo, sm.Env); locked { - postBotMessage(cu, "The env is locked. You should unlock the env before deploying.") + postBotMessage(cu, fmt.Sprintf("The `%s` environment is locked. You should unlock the environment before deploying.", sm.Env)) c.Status(http.StatusOK) return } else if err != nil { diff --git a/internal/server/slack/deploy_test.go b/internal/server/slack/deploy_test.go index 0ffa9c4b..2925e2cf 100644 --- a/internal/server/slack/deploy_test.go +++ b/internal/server/slack/deploy_test.go @@ -18,10 +18,6 @@ import ( "github.com/gitploy-io/gitploy/vo" ) -const ( - pathPostMessage string = "chat.postMessage" -) - func TestSlack_interactDeploy(t *testing.T) { t.Run("Create a new deployment with payload.", func(t *testing.T) { m := mock.NewMockInteractor(gomock.NewController(t)) diff --git a/internal/server/slack/interface.go b/internal/server/slack/interface.go index 3b768c26..a5abbe4c 100644 --- a/internal/server/slack/interface.go +++ b/internal/server/slack/interface.go @@ -35,7 +35,12 @@ type ( CreateApproval(ctx context.Context, a *ent.Approval) (*ent.Approval, error) + ListLocksOfRepo(ctx context.Context, r *ent.Repo) ([]*ent.Lock, error) + FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) HasLockOfRepoForEnv(ctx context.Context, r *ent.Repo, env string) (bool, error) + FindLockByID(ctx context.Context, id int) (*ent.Lock, error) + CreateLock(ctx context.Context, l *ent.Lock) (*ent.Lock, error) + DeleteLock(ctx context.Context, l *ent.Lock) error SubscribeEvent(fn func(e *ent.Event)) error UnsubscribeEvent(fn func(e *ent.Event)) error diff --git a/internal/server/slack/lock.go b/internal/server/slack/lock.go index f5c5da29..5b6589a1 100644 --- a/internal/server/slack/lock.go +++ b/internal/server/slack/lock.go @@ -3,144 +3,316 @@ package slack import ( "fmt" "net/http" - "strings" "github.com/gin-gonic/gin" "github.com/slack-go/slack" "go.uber.org/zap" "github.com/gitploy-io/gitploy/ent" + "github.com/gitploy-io/gitploy/ent/callback" "github.com/gitploy-io/gitploy/ent/perm" + "github.com/gitploy-io/gitploy/vo" +) + +type ( + lockViewSubmission struct { + Env string + } ) func (s *Slack) handleLockCmd(c *gin.Context) { ctx := c.Request.Context() - // SlashCommandParse hvae to be success because - // it has parsed in the Cmd method. - cmd, _ := slack.SlashCommandParse(c.Request) + av, _ := c.Get(KeyCmd) + cmd := av.(slack.SlashCommand) - cu, err := s.i.FindChatUserByID(ctx, cmd.UserID) + bv, _ := c.Get(KeyChatUser) + cu := bv.(*ent.ChatUser) + + s.log.Debug("Processing lock command.", zap.String("command", cmd.Text)) + ns, n := parseCmd(cmd.Text) + + r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Slack is not connected with Gitploy.") + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s/%s` repository is not found.", ns, n)) c.Status(http.StatusOK) return } else if err != nil { - s.log.Error("It has failed to get chat user.", zap.Error(err)) + s.log.Error("It has failed to get the repo.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - args := strings.Split(cmd.Text, " ") - - ns, n, err := parseFullName(args[1]) - if err != nil { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("`%s` is invalid repository format.", args[1])) + // Validate the perm for the repo. + if p, err := s.i.FindPermOfRepo(ctx, r, cu.Edges.User); !(p.RepoPerm == perm.RepoPermWrite || p.RepoPerm == perm.RepoPermAdmin) { + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Write perm is required to lock the environment.") c.Status(http.StatusOK) return + } else if err != nil { + s.log.Error("It has failed to get the perm.", zap.Error(err)) + c.Status(http.StatusInternalServerError) + return } - r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) - if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s` repository is not found.", args[1])) + // Build the modal with unlocked envs. + config, err := s.i.GetConfig(ctx, cu.Edges.User, r) + if vo.IsConfigNotFoundError(err) || vo.IsConfigParseError(err) { + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "The config is invalid.") c.Status(http.StatusOK) return } else if err != nil { - s.log.Error("It has failed to get the repo.", zap.Error(err)) + s.log.Error("It has failed to get the config file.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - p, err := s.i.FindPermOfRepo(ctx, r, cu.Edges.User) - if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s` repository is not found.", args[1])) - c.Status(http.StatusOK) - return - } else if err != nil { - s.log.Error("It has failed to get the perm.", zap.Error(err)) + locks, err := s.i.ListLocksOfRepo(ctx, r) + if err != nil { + s.log.Error("It has failed to list locks.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - if p.RepoPerm != perm.RepoPermAdmin { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Only admin can lock the repository.") - c.Status(http.StatusOK) + cb, err := s.i.CreateCallback(ctx, &ent.Callback{ + Type: callback.TypeLock, + RepoID: r.ID, + }) + if err != nil { + s.log.Error("It has failed to create a new callback.", zap.Error(err)) + c.Status(http.StatusInternalServerError) return } - // r.Locked = true - if r, err = s.i.UpdateRepo(ctx, r); err != nil { - s.log.Error("It has failed to update the repo.", zap.Error(err)) + _, err = slack.New(cu.BotToken). + OpenViewContext(ctx, cmd.TriggerID, buildLockView(cb.Hash, config, locks)) + if err != nil { + s.log.Error("It has failed to open a new view.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("Lock the `%s` repository successfully.", args[1])) c.Status(http.StatusOK) } +func buildLockView(callbackID string, c *vo.Config, locks []*ent.Lock) slack.ModalViewRequest { + hasLocked := func(env string) bool { + for _, lock := range locks { + if lock.Env == env { + return true + } + } + + return false + } + + envs := []*slack.OptionBlockObject{} + for _, env := range c.Envs { + if hasLocked(env.Name) { + continue + } + + envs = append(envs, &slack.OptionBlockObject{ + Text: &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: env.Name, + }, + Value: env.Name, + }) + } + + return slack.ModalViewRequest{ + Type: slack.VTModal, + CallbackID: callbackID, + Title: slack.NewTextBlockObject(slack.PlainTextType, "Lock", false, false), + Submit: slack.NewTextBlockObject(slack.PlainTextType, "Submit", false, false), + Close: slack.NewTextBlockObject(slack.PlainTextType, "Close", false, false), + Blocks: slack.Blocks{ + BlockSet: []slack.Block{ + slack.NewInputBlock( + blockEnv, + slack.NewTextBlockObject(slack.PlainTextType, "Environment", false, false), + slack.NewOptionsSelectBlockElement( + slack.OptTypeStatic, + slack.NewTextBlockObject(slack.PlainTextType, "Select the environment", false, false), + actionEnv, + envs..., + ), + ), + }, + }, + } +} + func (s *Slack) handleUnlockCmd(c *gin.Context) { ctx := c.Request.Context() - // SlashCommandParse hvae to be success because - // it has parsed in the Cmd method. - cmd, _ := slack.SlashCommandParse(c.Request) + av, _ := c.Get(KeyCmd) + cmd := av.(slack.SlashCommand) + + bv, _ := c.Get(KeyChatUser) + cu := bv.(*ent.ChatUser) - cu, err := s.i.FindChatUserByID(ctx, cmd.UserID) + s.log.Debug("Processing lock command.", zap.String("command", cmd.Text)) + ns, n := parseCmd(cmd.Text) + + r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Slack is not connected with Gitploy.") + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s/%s` repository is not found.", ns, n)) c.Status(http.StatusOK) return } else if err != nil { - s.log.Error("It has failed to get chat user.", zap.Error(err)) + s.log.Error("It has failed to get the repo.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - args := strings.Split(cmd.Text, " ") - - ns, n, err := parseFullName(args[1]) - if err != nil { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("`%s` is invalid repository format.", args[1])) + // Validate the perm for the repo. + if p, err := s.i.FindPermOfRepo(ctx, r, cu.Edges.User); !(p.RepoPerm == perm.RepoPermWrite || p.RepoPerm == perm.RepoPermAdmin) { + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Write perm is required to lock the environment.") c.Status(http.StatusOK) return + } else if err != nil { + s.log.Error("It has failed to get the perm.", zap.Error(err)) + c.Status(http.StatusInternalServerError) + return } - r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) - if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s` repository is not found.", args[1])) + // Build the modal with unlocked envs. + locks, err := s.i.ListLocksOfRepo(ctx, r) + if len(locks) == 0 { + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("There is no locked envs in the `%s/%s` repository.", ns, n)) c.Status(http.StatusOK) return } else if err != nil { - s.log.Error("It has failed to get the repo.", zap.Error(err)) + s.log.Error("It has failed to list locks.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - p, err := s.i.FindPermOfRepo(ctx, r, cu.Edges.User) - if ent.IsNotFound(err) { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("The `%s` repository is not found.", args[1])) - c.Status(http.StatusOK) + cb, err := s.i.CreateCallback(ctx, &ent.Callback{ + Type: callback.TypeUnlock, + RepoID: r.ID, + }) + if err != nil { + s.log.Error("It has failed to create a new callback.", zap.Error(err)) + c.Status(http.StatusInternalServerError) return - } else if err != nil { - s.log.Error("It has failed to get the perm.", zap.Error(err)) + } + + _, err = slack.New(cu.BotToken). + OpenViewContext(ctx, cmd.TriggerID, buildUnlockView(cb.Hash, locks)) + if err != nil { + s.log.Error("It has failed to open a new view.", zap.Error(err)) + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func buildUnlockView(callbackID string, locks []*ent.Lock) slack.ModalViewRequest { + envs := []*slack.OptionBlockObject{} + for _, lock := range locks { + + envs = append(envs, &slack.OptionBlockObject{ + Text: &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: lock.Env, + }, + Value: lock.Env, + }) + } + + return slack.ModalViewRequest{ + Type: slack.VTModal, + CallbackID: callbackID, + Title: slack.NewTextBlockObject(slack.PlainTextType, "Unlock", false, false), + Submit: slack.NewTextBlockObject(slack.PlainTextType, "Submit", false, false), + Close: slack.NewTextBlockObject(slack.PlainTextType, "Close", false, false), + Blocks: slack.Blocks{ + BlockSet: []slack.Block{ + slack.NewInputBlock( + blockEnv, + slack.NewTextBlockObject(slack.PlainTextType, "Environment", false, false), + slack.NewOptionsSelectBlockElement( + slack.OptTypeStatic, + slack.NewTextBlockObject(slack.PlainTextType, "Select the environment", false, false), + actionEnv, + envs..., + ), + ), + }, + }, + } +} + +func (s *Slack) interactLock(c *gin.Context) { + ctx := c.Request.Context() + + iv, _ := c.Get(KeyIntr) + itr := iv.(slack.InteractionCallback) + + cv, _ := c.Get(KeyChatUser) + cu := cv.(*ent.ChatUser) + + cb, _ := s.i.FindCallbackByHash(ctx, itr.View.CallbackID) + + sm := parseLockViewSubmissions(itr) + + if _, err := s.i.CreateLock(ctx, &ent.Lock{ + Env: sm.Env, + UserID: cu.Edges.User.ID, + RepoID: cb.Edges.Repo.ID, + }); err != nil { + s.log.Error("It has failed to lock the env.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - if p.RepoPerm != perm.RepoPermAdmin { - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, "Only admin can unlock the repository.") + postBotMessage(cu, fmt.Sprintf("Success to lock the `%s` environment of the `%s` repository.", sm.Env, cb.Edges.Repo.GetFullName())) + c.Status(http.StatusOK) +} + +func (s *Slack) interactUnlock(c *gin.Context) { + ctx := c.Request.Context() + + iv, _ := c.Get(KeyIntr) + itr := iv.(slack.InteractionCallback) + + cv, _ := c.Get(KeyChatUser) + cu := cv.(*ent.ChatUser) + + cb, _ := s.i.FindCallbackByHash(ctx, itr.View.CallbackID) + + sm := parseLockViewSubmissions(itr) + + lock, err := s.i.FindLockOfRepoByEnv(ctx, cb.Edges.Repo, sm.Env) + if ent.IsNotFound(err) { + postBotMessage(cu, fmt.Sprintf("The `%s` environment is not locked.", sm.Env)) c.Status(http.StatusOK) + } else if err != nil { + s.log.Error("It has failed to find the lock.", zap.Error(err)) + c.Status(http.StatusInternalServerError) return } - // r.Locked = false - if r, err = s.i.UpdateRepo(ctx, r); err != nil { - s.log.Error("It has failed to update the repo.", zap.Error(err)) + if err := s.i.DeleteLock(ctx, lock); err != nil { + s.log.Error("It has failed to unlock the env.", zap.Error(err)) c.Status(http.StatusInternalServerError) return } - postResponseMessage(cmd.ChannelID, cmd.ResponseURL, fmt.Sprintf("Unlock the `%s` repository successfully.", args[1])) + postBotMessage(cu, fmt.Sprintf("Success to unlock the `%s` environment of the `%s` repository.", sm.Env, cb.Edges.Repo.GetFullName())) c.Status(http.StatusOK) } + +func parseLockViewSubmissions(itr slack.InteractionCallback) *lockViewSubmission { + sm := &lockViewSubmission{} + + values := itr.View.State.Values + if v, ok := values[blockEnv][actionEnv]; ok { + sm.Env = v.SelectedOption.Value + } + + return sm +} diff --git a/internal/server/slack/mock/interactor.go b/internal/server/slack/mock/interactor.go index 67dfea71..1cd3d174 100644 --- a/internal/server/slack/mock/interactor.go +++ b/internal/server/slack/mock/interactor.go @@ -110,6 +110,21 @@ func (mr *MockInteractorMockRecorder) CreateEvent(ctx, e interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateEvent", reflect.TypeOf((*MockInteractor)(nil).CreateEvent), ctx, e) } +// CreateLock mocks base method. +func (m *MockInteractor) CreateLock(ctx context.Context, l *ent.Lock) (*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateLock", ctx, l) + ret0, _ := ret[0].(*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateLock indicates an expected call of CreateLock. +func (mr *MockInteractorMockRecorder) CreateLock(ctx, l interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLock", reflect.TypeOf((*MockInteractor)(nil).CreateLock), ctx, l) +} + // DeleteChatUser mocks base method. func (m *MockInteractor) DeleteChatUser(ctx context.Context, cu *ent.ChatUser) error { m.ctrl.T.Helper() @@ -124,6 +139,20 @@ func (mr *MockInteractorMockRecorder) DeleteChatUser(ctx, cu interface{}) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatUser", reflect.TypeOf((*MockInteractor)(nil).DeleteChatUser), ctx, cu) } +// DeleteLock mocks base method. +func (m *MockInteractor) DeleteLock(ctx context.Context, l *ent.Lock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLock", ctx, l) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLock indicates an expected call of DeleteLock. +func (mr *MockInteractorMockRecorder) DeleteLock(ctx, l interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLock", reflect.TypeOf((*MockInteractor)(nil).DeleteLock), ctx, l) +} + // Deploy mocks base method. func (m *MockInteractor) Deploy(ctx context.Context, u *ent.User, re *ent.Repo, d *ent.Deployment, env *vo.Env) (*ent.Deployment, error) { m.ctrl.T.Helper() @@ -184,6 +213,36 @@ func (mr *MockInteractorMockRecorder) FindDeploymentByID(ctx, id interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindDeploymentByID", reflect.TypeOf((*MockInteractor)(nil).FindDeploymentByID), ctx, id) } +// FindLockByID mocks base method. +func (m *MockInteractor) FindLockByID(ctx context.Context, id int) (*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLockByID", ctx, id) + ret0, _ := ret[0].(*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLockByID indicates an expected call of FindLockByID. +func (mr *MockInteractorMockRecorder) FindLockByID(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockByID", reflect.TypeOf((*MockInteractor)(nil).FindLockByID), ctx, id) +} + +// FindLockOfRepoByEnv mocks base method. +func (m *MockInteractor) FindLockOfRepoByEnv(ctx context.Context, r *ent.Repo, env string) (*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLockOfRepoByEnv", ctx, r, env) + ret0, _ := ret[0].(*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLockOfRepoByEnv indicates an expected call of FindLockOfRepoByEnv. +func (mr *MockInteractorMockRecorder) FindLockOfRepoByEnv(ctx, r, env interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLockOfRepoByEnv", reflect.TypeOf((*MockInteractor)(nil).FindLockOfRepoByEnv), ctx, r, env) +} + // FindPermOfRepo mocks base method. func (m *MockInteractor) FindPermOfRepo(ctx context.Context, r *ent.Repo, u *ent.User) (*ent.Perm, error) { m.ctrl.T.Helper() @@ -334,6 +393,21 @@ func (mr *MockInteractorMockRecorder) ListDeploymentsOfRepo(ctx, r, env, status, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDeploymentsOfRepo", reflect.TypeOf((*MockInteractor)(nil).ListDeploymentsOfRepo), ctx, r, env, status, page, perPage) } +// ListLocksOfRepo mocks base method. +func (m *MockInteractor) ListLocksOfRepo(ctx context.Context, r *ent.Repo) ([]*ent.Lock, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListLocksOfRepo", ctx, r) + ret0, _ := ret[0].([]*ent.Lock) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListLocksOfRepo indicates an expected call of ListLocksOfRepo. +func (mr *MockInteractorMockRecorder) ListLocksOfRepo(ctx, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLocksOfRepo", reflect.TypeOf((*MockInteractor)(nil).ListLocksOfRepo), ctx, r) +} + // ListPermsOfRepo mocks base method. func (m *MockInteractor) ListPermsOfRepo(ctx context.Context, r *ent.Repo, q string, page, perPage int) ([]*ent.Perm, error) { m.ctrl.T.Helper() diff --git a/internal/server/slack/rollback.go b/internal/server/slack/rollback.go index a2dbf417..14844828 100644 --- a/internal/server/slack/rollback.go +++ b/internal/server/slack/rollback.go @@ -45,7 +45,7 @@ func (s *Slack) handleRollbackCmd(c *gin.Context) { bv, _ := c.Get(KeyChatUser) cu := bv.(*ent.ChatUser) - s.log.Debug("Process deploy command.", zap.String("command", cmd.Text)) + s.log.Debug("Processing rollback command.", zap.String("command", cmd.Text)) ns, n := parseCmd(cmd.Text) r, err := s.i.FindRepoOfUserByNamespaceName(ctx, cu.Edges.User, ns, n) @@ -240,7 +240,7 @@ func (s *Slack) interactRollback(c *gin.Context) { } if locked, err := s.i.HasLockOfRepoForEnv(ctx, cb.Edges.Repo, d.Env); locked { - postBotMessage(cu, "The env is locked. You should unlock the env before deploying.") + postBotMessage(cu, fmt.Sprintf("The `%s` environment is locked. You should unlock the environment before deploying.", d.Env)) c.Status(http.StatusOK) return } else if err != nil { diff --git a/internal/server/slack/slack.go b/internal/server/slack/slack.go index d4a8796b..743693b2 100644 --- a/internal/server/slack/slack.go +++ b/internal/server/slack/slack.go @@ -4,7 +4,6 @@ import ( "context" "net/http" "regexp" - "strings" "github.com/gin-gonic/gin" "github.com/gitploy-io/gitploy/ent" @@ -33,6 +32,17 @@ type ( } ) +const ( + help = "Below are the commands you can use:\n\n" + + "*Deploy*\n" + + "`/gitploy deploy OWNER/REPO` - Create a new deployment for OWNER/REPO.\n\n" + + "*Rollback*\n" + + "`/gitploy rollback OWNER/REPO` - Rollback by the deployment for OWNER/REPO.\n\n" + + "*Lock/Unlock*\n" + + "`/gitploy lock OWNER/REPO` - Lock the environment to disable deploying.\n" + + "`/gitploy unlock OWNER/REPO` - Unlock the environment.\n\n" +) + func NewSlack(c *SlackConfig) *Slack { s := &Slack{ host: c.ServerHost, @@ -59,47 +69,15 @@ func (s *Slack) Cmd(c *gin.Context) { s.handleDeployCmd(c) } else if matched, _ := regexp.MatchString("^rollback[[:blank:]]+[0-9A-Za-z._-]*/[0-9A-Za-z._-]*$", cmd.Text); matched { s.handleRollbackCmd(c) + } else if matched, _ := regexp.MatchString("^lock[[:blank:]]+[0-9A-Za-z._-]*/[0-9A-Za-z._-]*$", cmd.Text); matched { + s.handleLockCmd(c) + } else if matched, _ := regexp.MatchString("^unlock[[:blank:]]+[0-9A-Za-z._-]*/[0-9A-Za-z._-]*$", cmd.Text); matched { + s.handleUnlockCmd(c) } else { - s.handleHelpCmd(cmd.ChannelID, cmd.ResponseURL) + postResponseMessage(cmd.ChannelID, cmd.ResponseURL, help) } } -func (s *Slack) handleHelpCmd(channelID, responseURL string) { - msg := strings.Join([]string{ - "Below are the commands you can use:\n", - "*Deploy*", - "`/gitploy deploy OWNER/REPO` - Create a new deployment for OWNER/REPO.\n", - "*Rollback*", - "`/gitploy rollback OWNER/REPO` - Rollback by the deployment for OWNER/REPO.\n", - "*Lock/Unlock*", - "`/gitploy lock OWNER/REPO` - Lock the repository to disable deploying.", - "`/gitploy unlock OWNER/REPO` - Unlock the repository to enable deploying.\n", - }, "\n") - - postResponseMessage(channelID, responseURL, msg) -} - -func postResponseMessage(channelID, responseURL, message string) error { - _, _, _, err := slack. - New(""). - SendMessage( - channelID, - slack.MsgOptionResponseURL(responseURL, "ephemeral"), - slack.MsgOptionText(message, false), - ) - return err -} - -func postBotMessage(cu *ent.ChatUser, message string) error { - _, _, _, err := slack. - New(cu.BotToken). - SendMessage( - cu.ID, - slack.MsgOptionText(message, false), - ) - return err -} - // Interact interacts interactive components (dialog, button). func (s *Slack) Interact(c *gin.Context) { ctx := c.Request.Context() @@ -118,20 +96,30 @@ func (s *Slack) Interact(c *gin.Context) { s.interactDeploy(c) } else if cb.Type == callback.TypeRollback { s.interactRollback(c) + } else if cb.Type == callback.TypeLock { + s.interactLock(c) + } else if cb.Type == callback.TypeUnlock { + s.interactUnlock(c) } } -func (s *Slack) InteractionCallbackParse(r *http.Request) (slack.InteractionCallback, error) { - r.ParseForm() - payload := r.PostForm.Get("payload") - - scb := slack.InteractionCallback{} - err := scb.UnmarshalJSON([]byte(payload)) - - // Trim backticked double quote for string type. - // https://github.com/slack-go/slack/issues/816 - state := strings.Trim(scb.State, "\"") - scb.State = state +func postResponseMessage(channelID, responseURL, message string) error { + _, _, _, err := slack. + New(""). + SendMessage( + channelID, + slack.MsgOptionResponseURL(responseURL, "ephemeral"), + slack.MsgOptionText(message, false), + ) + return err +} - return scb, err +func postBotMessage(cu *ent.ChatUser, message string) error { + _, _, _, err := slack. + New(cu.BotToken). + SendMessage( + cu.ID, + slack.MsgOptionText(message, false), + ) + return err }