diff --git a/charts/Feature.yaml b/charts/Feature.yaml index 0543d11f1..88781f002 100644 --- a/charts/Feature.yaml +++ b/charts/Feature.yaml @@ -142,6 +142,19 @@ values: template: | {{ .Env.nais_api_db_password | quote }} + slack.token: + displayName: Slack token + description: Token for the Slack API + config: + type: string + secret: true + + slack.feedbackChannel: + displayName: Slack feedback channel + description: The Slack channel to post feedback to + config: + type: string + usersync.serviceAccount: displayName: Service account for user sync description: The service account used to sync users from GSuite @@ -165,7 +178,6 @@ values: description: Enable Unleash feature flag config: type: bool - default: false unleash.namespace: displayName: Unleash namespace @@ -173,4 +185,4 @@ values: config: type: string computed: - template: '"{{ .Management.bifrost_unleash_namespace}}"' \ No newline at end of file + template: '"{{ .Management.bifrost_unleash_namespace}}"' diff --git a/charts/templates/secret.yaml b/charts/templates/secret.yaml index 04b7d7959..477d3795d 100644 --- a/charts/templates/secret.yaml +++ b/charts/templates/secret.yaml @@ -10,4 +10,6 @@ stringData: DEPENDENCYTRACK_PASSWORD: "{{ .Values.dependencytrack.password }}" OAUTH_CLIENT_SECRET: "{{ .Values.oauth.clientSecret }}" STATIC_SERVICE_ACCOUNTS: {{ .Values.staticServiceAccounts | quote }} - DATABASE_URL: "postgres://{{ .Values.database.user }}:{{ .Values.database.password }}@127.0.0.1:5432/{{ .Values.database.name }}?sslmode=disable" \ No newline at end of file + DATABASE_URL: "postgres://{{ .Values.database.user }}:{{ .Values.database.password }}@127.0.0.1:5432/{{ .Values.database.name }}?sslmode=disable" + SLACK_FEEDBACK_CHANNEL: {{ .Values.slack.feedbackChannel | quote }} + SLACK_API_TOKEN: {{ .Values.slack.token }} diff --git a/charts/values.yaml b/charts/values.yaml index 5fc3bbf44..e67a694f3 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -51,6 +51,10 @@ oauth: # mapped in fasit clientID: "" clientSecret: "" +slack: + feedbackChannel: "console-user-feedback" + token: "" # Config in fasit + staticServiceAccounts: "" # mapped in fasit usersync: diff --git a/go.mod b/go.mod index a65932b73..9ab2c85e8 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/rs/cors v1.11.0 github.com/sethvargo/go-envconfig v1.0.3 github.com/sirupsen/logrus v1.9.3 + github.com/slack-go/slack v0.13.1 github.com/sourcegraph/conc v0.3.0 github.com/sqlc-dev/sqlc v1.26.0 github.com/stretchr/testify v1.9.0 @@ -63,6 +64,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 sigs.k8s.io/yaml v1.4.0 + ) require ( diff --git a/go.sum b/go.sum index a452ac072..f99e79779 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqw github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -214,6 +216,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -235,6 +238,7 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= @@ -455,6 +459,8 @@ github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slack-go/slack v0.13.1 h1:6UkM3U1OnbhPsYeb1IMkQ6HSNOSikWluwOncJt4Tz/o= +github.com/slack-go/slack v0.13.1/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -478,6 +484,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index c7188c288..a7dd5fde9 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -13,6 +13,7 @@ import ( "github.com/nais/api/internal/bigquery" "github.com/nais/api/internal/kafka" "github.com/nais/api/internal/opensearch" + "github.com/nais/api/internal/slack" "github.com/nais/api/internal/unleash" @@ -196,6 +197,7 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { dependencyTrackClient, resourceUsageClient, db, + cfg.Tenant, cfg.TenantDomain, usersyncTrigger, auditLogger, @@ -210,6 +212,7 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { kafka.NewClient(k8sClient.Informers(), log, db), unleashMgr, audit.NewAuditor(db), + slack.New(cfg.Slack.Token, cfg.Slack.FeedbackChannel), ) graphHandler, err := graph.NewHandler(gengql.Config{ diff --git a/internal/cmd/api/config.go b/internal/cmd/api/config.go index e6700d437..1ea577800 100644 --- a/internal/cmd/api/config.go +++ b/internal/cmd/api/config.go @@ -150,6 +150,11 @@ type oAuthConfig struct { RedirectURL string `env:"OAUTH_REDIRECT_URL"` } +type slackConfig struct { + Token string `env:"SLACK_TOKEN"` + FeedbackChannel string `env:"SLACK_FEEDBACK_CHANNEL"` +} + type unleashConfig struct { // Enabled When set to true, the Unleash feature flag service will be enabled. Enabled bool `env:"UNLEASH_ENABLED"` @@ -200,6 +205,7 @@ type Config struct { Hookd hookdConfig OAuth oAuthConfig Unleash unleashConfig + Slack slackConfig } // NewConfig creates a new configuration instance from environment variables diff --git a/internal/graph/deployinfo.resolvers_test.go b/internal/graph/deployinfo.resolvers_test.go index 6d77be659..d407919ff 100644 --- a/internal/graph/deployinfo.resolvers_test.go +++ b/internal/graph/deployinfo.resolvers_test.go @@ -8,6 +8,7 @@ import ( "github.com/nais/api/internal/graph" "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/scalar" + "github.com/nais/api/internal/slack/fake" "github.com/nais/api/internal/thirdparty/hookd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -94,6 +95,7 @@ func Test_deployInfoResolver_History(t *testing.T) { nil, nil, nil, + "example", "example.com", nil, nil, @@ -108,6 +110,7 @@ func Test_deployInfoResolver_History(t *testing.T) { nil, nil, nil, + fake.NewFakeSlackClient(), ). DeployInfo(). History(ctx, deployInfo, nil, nil) diff --git a/internal/graph/feedback.resolvers.go b/internal/graph/feedback.resolvers.go new file mode 100644 index 000000000..07ef07a1c --- /dev/null +++ b/internal/graph/feedback.resolvers.go @@ -0,0 +1,36 @@ +package graph + +import ( + "context" + + "github.com/nais/api/internal/graph/model" + "k8s.io/utils/ptr" +) + +func (r *mutationResolver) CreateFeedback(ctx context.Context, input model.CreateFeedbackInput) (*model.CreateFeedbackResult, error) { + if len(input.Details) == 0 { + return &model.CreateFeedbackResult{ + Created: false, + Error: ptr.To("Feedback details are required"), + }, nil + } + if len(input.Details) > 3000 { + return &model.CreateFeedbackResult{ + Created: false, + Error: ptr.To("Feedback details must be no more than 3000 characters"), + }, nil + } + + messageOptions := r.slackClient.GetFeedbackMessageOptions(ctx, r.tenant, input) + if _, _, err := r.slackClient.PostFeedbackMessage(messageOptions); err != nil { + return &model.CreateFeedbackResult{ + Created: false, + Error: ptr.To(err.Error()), + }, err + } + + return &model.CreateFeedbackResult{ + Created: true, + Error: nil, + }, nil +} diff --git a/internal/graph/gengql/generated.go b/internal/graph/gengql/generated.go index 114f5be6a..90599f94f 100644 --- a/internal/graph/gengql/generated.go +++ b/internal/graph/gengql/generated.go @@ -465,6 +465,11 @@ type ComplexityRoot struct { Sum func(childComplexity int) int } + CreateFeedbackResult struct { + Created func(childComplexity int) int + Error func(childComplexity int) int + } + CurrentResourceUtilization struct { CPU func(childComplexity int) int Memory func(childComplexity int) int @@ -815,6 +820,7 @@ type ComplexityRoot struct { ChangeDeployKey func(childComplexity int, team slug.Slug) int ConfigureReconciler func(childComplexity int, name string, config []*model.ReconcilerConfigInput) int ConfirmTeamDeletion func(childComplexity int, key string) int + CreateFeedback func(childComplexity int, input model.CreateFeedbackInput) int CreateSecret func(childComplexity int, name string, team slug.Slug, env string, data []*model.VariableInput) int CreateTeam func(childComplexity int, input model.CreateTeamInput) int CreateUnleashForTeam func(childComplexity int, team slug.Slug) int @@ -1537,6 +1543,7 @@ type KafkaTopicAclResolver interface { type MutationResolver interface { DeleteApp(ctx context.Context, name string, team slug.Slug, env string) (*model.DeleteAppResult, error) RestartApp(ctx context.Context, name string, team slug.Slug, env string) (*model.RestartAppResult, error) + CreateFeedback(ctx context.Context, input model.CreateFeedbackInput) (*model.CreateFeedbackResult, error) SuppressFinding(ctx context.Context, analysisState string, comment string, componentID string, projectID string, vulnerabilityID string, suppressedBy string, suppress bool, team slug.Slug) (*model.AnalysisTrail, error) DeleteJob(ctx context.Context, name string, team slug.Slug, env string) (*model.DeleteJobResult, error) EnableReconciler(ctx context.Context, name string) (*model.Reconciler, error) @@ -3327,6 +3334,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.CostSeries.Sum(childComplexity), true + case "CreateFeedbackResult.created": + if e.complexity.CreateFeedbackResult.Created == nil { + break + } + + return e.complexity.CreateFeedbackResult.Created(childComplexity), true + + case "CreateFeedbackResult.error": + if e.complexity.CreateFeedbackResult.Error == nil { + break + } + + return e.complexity.CreateFeedbackResult.Error(childComplexity), true + case "CurrentResourceUtilization.cpu": if e.complexity.CurrentResourceUtilization.CPU == nil { break @@ -4786,6 +4807,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ConfirmTeamDeletion(childComplexity, args["key"].(string)), true + case "Mutation.createFeedback": + if e.complexity.Mutation.CreateFeedback == nil { + break + } + + args, err := ec.field_Mutation_createFeedback_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CreateFeedback(childComplexity, args["input"].(model.CreateFeedbackInput)), true + case "Mutation.createSecret": if e.complexity.Mutation.CreateSecret == nil { break @@ -7943,6 +7976,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec := executionContext{rc, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( ec.unmarshalInputAuditEventsFilter, + ec.unmarshalInputCreateFeedbackInput, ec.unmarshalInputCreateTeamInput, ec.unmarshalInputEnvCostFilter, ec.unmarshalInputGitHubRepositoriesFilter, @@ -8871,6 +8905,35 @@ directive @admin on FIELD_DEFINITION slackAlertsChannel: String! secrets: [Secret!]! @auth } +`, BuiltIn: false}, + {Name: "../graphqls/feedback.graphqls", Input: `extend type Mutation { + createFeedback( + "The feedback content." + input: CreateFeedbackInput! + ): CreateFeedbackResult! +} + +input CreateFeedbackInput { + "The feedback content." + details: String! + uri: String! + anonymous: Boolean! + type: FeedbackType! +} + +enum FeedbackType { + "Feedback type for the feedback." + BUG + CHANGE_REQUEST + OTHER + QUESTION +} + +type CreateFeedbackResult { + "Whether the feedback was created or not." + created: Boolean! + error: String +} `, BuiltIn: false}, {Name: "../graphqls/github_repo.graphqls", Input: `"GitHub repository type." type GitHubRepository { @@ -11175,6 +11238,21 @@ func (ec *executionContext) field_Mutation_confirmTeamDeletion_args(ctx context. return args, nil } +func (ec *executionContext) field_Mutation_createFeedback_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.CreateFeedbackInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNCreateFeedbackInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateFeedbackInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_createSecret_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -24615,6 +24693,91 @@ func (ec *executionContext) fieldContext_CostSeries_data(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _CreateFeedbackResult_created(ctx context.Context, field graphql.CollectedField, obj *model.CreateFeedbackResult) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CreateFeedbackResult_created(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Created, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CreateFeedbackResult_created(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreateFeedbackResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CreateFeedbackResult_error(ctx context.Context, field graphql.CollectedField, obj *model.CreateFeedbackResult) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CreateFeedbackResult_error(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Error, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CreateFeedbackResult_error(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreateFeedbackResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _CurrentResourceUtilization_timestamp(ctx context.Context, field graphql.CollectedField, obj *model.CurrentResourceUtilization) (ret graphql.Marshaler) { fc, err := ec.fieldContext_CurrentResourceUtilization_timestamp(ctx, field) if err != nil { @@ -33936,6 +34099,67 @@ func (ec *executionContext) fieldContext_Mutation_restartApp(ctx context.Context return fc, nil } +func (ec *executionContext) _Mutation_createFeedback(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createFeedback(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CreateFeedback(rctx, fc.Args["input"].(model.CreateFeedbackInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.CreateFeedbackResult) + fc.Result = res + return ec.marshalNCreateFeedbackResult2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateFeedbackResult(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createFeedback(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "created": + return ec.fieldContext_CreateFeedbackResult_created(ctx, field) + case "error": + return ec.fieldContext_CreateFeedbackResult_error(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CreateFeedbackResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createFeedback_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_suppressFinding(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_suppressFinding(ctx, field) if err != nil { @@ -58452,6 +58676,54 @@ func (ec *executionContext) unmarshalInputAuditEventsFilter(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputCreateFeedbackInput(ctx context.Context, obj interface{}) (model.CreateFeedbackInput, error) { + var it model.CreateFeedbackInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"details", "uri", "anonymous", "type"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "details": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("details")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Details = data + case "uri": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("uri")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.URI = data + case "anonymous": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("anonymous")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.Anonymous = data + case "type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("type")) + data, err := ec.unmarshalNFeedbackType2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐFeedbackType(ctx, v) + if err != nil { + return it, err + } + it.Type = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputCreateTeamInput(ctx context.Context, obj interface{}) (model.CreateTeamInput, error) { var it model.CreateTeamInput asMap := map[string]interface{}{} @@ -62917,6 +63189,47 @@ func (ec *executionContext) _CostSeries(ctx context.Context, sel ast.SelectionSe return out } +var createFeedbackResultImplementors = []string{"CreateFeedbackResult"} + +func (ec *executionContext) _CreateFeedbackResult(ctx context.Context, sel ast.SelectionSet, obj *model.CreateFeedbackResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, createFeedbackResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreateFeedbackResult") + case "created": + out.Values[i] = ec._CreateFeedbackResult_created(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "error": + out.Values[i] = ec._CreateFeedbackResult_error(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var currentResourceUtilizationImplementors = []string{"CurrentResourceUtilization"} func (ec *executionContext) _CurrentResourceUtilization(ctx context.Context, sel ast.SelectionSet, obj *model.CurrentResourceUtilization) graphql.Marshaler { @@ -65829,6 +66142,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createFeedback": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createFeedback(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "suppressFinding": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_suppressFinding(ctx, field) @@ -74844,6 +75164,25 @@ func (ec *executionContext) marshalNCostSeries2ᚖgithubᚗcomᚋnaisᚋapiᚋin return ec._CostSeries(ctx, sel, v) } +func (ec *executionContext) unmarshalNCreateFeedbackInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateFeedbackInput(ctx context.Context, v interface{}) (model.CreateFeedbackInput, error) { + res, err := ec.unmarshalInputCreateFeedbackInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCreateFeedbackResult2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateFeedbackResult(ctx context.Context, sel ast.SelectionSet, v model.CreateFeedbackResult) graphql.Marshaler { + return ec._CreateFeedbackResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCreateFeedbackResult2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateFeedbackResult(ctx context.Context, sel ast.SelectionSet, v *model.CreateFeedbackResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CreateFeedbackResult(ctx, sel, v) +} + func (ec *executionContext) unmarshalNCreateTeamInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐCreateTeamInput(ctx context.Context, v interface{}) (model.CreateTeamInput, error) { res, err := ec.unmarshalInputCreateTeamInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -75354,6 +75693,16 @@ func (ec *executionContext) marshalNExternal2ᚖgithubᚗcomᚋnaisᚋapiᚋinte return ec._External(ctx, sel, v) } +func (ec *executionContext) unmarshalNFeedbackType2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐFeedbackType(ctx context.Context, v interface{}) (model.FeedbackType, error) { + var res model.FeedbackType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNFeedbackType2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐFeedbackType(ctx context.Context, sel ast.SelectionSet, v model.FeedbackType) graphql.Marshaler { + return v +} + func (ec *executionContext) marshalNFinding2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋmodelᚐFindingᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Finding) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/internal/graph/graphqls/feedback.graphqls b/internal/graph/graphqls/feedback.graphqls new file mode 100644 index 000000000..7fd011559 --- /dev/null +++ b/internal/graph/graphqls/feedback.graphqls @@ -0,0 +1,28 @@ +extend type Mutation { + createFeedback( + "The feedback content." + input: CreateFeedbackInput! + ): CreateFeedbackResult! +} + +input CreateFeedbackInput { + "The feedback content." + details: String! + uri: String! + anonymous: Boolean! + type: FeedbackType! +} + +enum FeedbackType { + "Feedback type for the feedback." + BUG + CHANGE_REQUEST + OTHER + QUESTION +} + +type CreateFeedbackResult { + "Whether the feedback was created or not." + created: Boolean! + error: String +} diff --git a/internal/graph/model/feedback.go b/internal/graph/model/feedback.go new file mode 100644 index 000000000..1fd65683d --- /dev/null +++ b/internal/graph/model/feedback.go @@ -0,0 +1,67 @@ +package model + +import ( + "fmt" + "io" + "strconv" +) + +type CreateFeedbackResult struct { + // Whether the feedback was created or not. + Created bool `json:"created"` + Error *string `json:"error,omitempty"` +} + +type CreateFeedbackInput struct { + // The feedback content. + Details string `json:"details"` + URI string `json:"uri"` + Anonymous bool `json:"anonymous"` + Type FeedbackType `json:"type"` +} + +type FeedbackType string + +const ( + // Feedback type for the feedback. + FeedbackTypeBug FeedbackType = "BUG" + FeedbackTypeChangeRequest FeedbackType = "CHANGE_REQUEST" + FeedbackTypeOther FeedbackType = "OTHER" + FeedbackTypeQuestion FeedbackType = "QUESTION" +) + +var AllFeedbackType = []FeedbackType{ + FeedbackTypeBug, + FeedbackTypeChangeRequest, + FeedbackTypeOther, + FeedbackTypeQuestion, +} + +func (e FeedbackType) IsValid() bool { + switch e { + case FeedbackTypeBug, FeedbackTypeChangeRequest, FeedbackTypeOther, FeedbackTypeQuestion: + return true + } + return false +} + +func (e FeedbackType) String() string { + return string(e) +} + +func (e *FeedbackType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = FeedbackType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid FeedbackType", str) + } + return nil +} + +func (e FeedbackType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/internal/graph/resolver.go b/internal/graph/resolver.go index b56187d5f..d2d112d01 100644 --- a/internal/graph/resolver.go +++ b/internal/graph/resolver.go @@ -6,6 +6,7 @@ import ( "slices" "github.com/nais/api/internal/audit" + "github.com/nais/api/internal/slack" "github.com/nais/api/internal/opensearch" @@ -136,6 +137,7 @@ type Resolver struct { log logrus.FieldLogger clusters ClusterList database database.Database + tenant string tenantDomain string usersyncTrigger chan<- uuid.UUID auditLogger auditlogger.AuditLogger @@ -148,6 +150,7 @@ type Resolver struct { kafkaClient *kafka.Client unleashMgr *unleash.Manager auditor *audit.Auditor + slackClient slack.SlackClient } // NewResolver creates a new GraphQL resolver with the given dependencies @@ -156,6 +159,7 @@ func NewResolver(hookdClient HookdClient, dependencyTrackClient DependencytrackClient, resourceUsageClient resourceusage.Client, db database.Database, + tenant string, tenantDomain string, usersyncTrigger chan<- uuid.UUID, auditLogger auditlogger.AuditLogger, @@ -170,12 +174,14 @@ func NewResolver(hookdClient HookdClient, kafkaClient *kafka.Client, unleashMgr *unleash.Manager, auditer *audit.Auditor, + slack slack.SlackClient, ) *Resolver { return &Resolver{ hookdClient: hookdClient, k8sClient: k8sClient, dependencyTrackClient: dependencyTrackClient, resourceUsageClient: resourceUsageClient, + tenant: tenant, tenantDomain: tenantDomain, usersyncTrigger: usersyncTrigger, auditLogger: auditLogger, @@ -192,6 +198,7 @@ func NewResolver(hookdClient HookdClient, kafkaClient: kafkaClient, unleashMgr: unleashMgr, auditor: auditer, + slackClient: slack, } } diff --git a/internal/graph/resourceusage.resolvers_test.go b/internal/graph/resourceusage.resolvers_test.go index c886ded6d..11e20caa0 100644 --- a/internal/graph/resourceusage.resolvers_test.go +++ b/internal/graph/resourceusage.resolvers_test.go @@ -9,6 +9,7 @@ import ( "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/graph/scalar" "github.com/nais/api/internal/resourceusage" + "github.com/nais/api/internal/slack/fake" "github.com/nais/api/internal/slug" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -44,6 +45,7 @@ func Test_queryResolver_ResourceUtilizationForApp(t *testing.T) { nil, resourceUsageClient, nil, + "example", "example.com", nil, nil, @@ -58,6 +60,7 @@ func Test_queryResolver_ResourceUtilizationForApp(t *testing.T) { nil, nil, nil, + fake.NewFakeSlackClient(), ). Query(). ResourceUtilizationForApp(ctx, "env", "team", "app", nil, nil) @@ -95,6 +98,7 @@ func Test_queryResolver_ResourceUtilizationForApp(t *testing.T) { nil, resourceUsageClient, nil, + "example", "example.com", nil, nil, @@ -109,6 +113,7 @@ func Test_queryResolver_ResourceUtilizationForApp(t *testing.T) { nil, nil, nil, + fake.NewFakeSlackClient(), ). Query(). ResourceUtilizationForApp(ctx, "env", "team", "app", &from, &to) diff --git a/internal/graph/serviceAccounts_test.go b/internal/graph/serviceAccounts_test.go index fd8f214a5..4beaac16e 100644 --- a/internal/graph/serviceAccounts_test.go +++ b/internal/graph/serviceAccounts_test.go @@ -12,6 +12,7 @@ import ( "github.com/nais/api/internal/database/gensql" "github.com/nais/api/internal/graph" "github.com/nais/api/internal/graph/model" + "github.com/nais/api/internal/slack/fake" "github.com/sirupsen/logrus/hooks/test" ) @@ -36,7 +37,7 @@ func TestMutationResolver_Roles(t *testing.T) { log, _ := test.NewNullLogger() usersyncTrigger := make(chan<- uuid.UUID) resolver := graph. - NewResolver(nil, nil, nil, nil, db, "example.com", usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil). + NewResolver(nil, nil, nil, nil, db, "example", "example.com", usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil, fake.NewFakeSlackClient()). ServiceAccount() t.Run("get roles for serviceAccount", func(t *testing.T) { diff --git a/internal/graph/teams_test.go b/internal/graph/teams_test.go index 1e700348d..228deadc5 100644 --- a/internal/graph/teams_test.go +++ b/internal/graph/teams_test.go @@ -19,6 +19,7 @@ import ( "github.com/nais/api/internal/graph/loader" "github.com/nais/api/internal/graph/model" "github.com/nais/api/internal/logger" + "github.com/nais/api/internal/slack/fake" "github.com/nais/api/internal/slug" "github.com/nais/api/pkg/protoapi" "github.com/sirupsen/logrus/hooks/test" @@ -72,11 +73,12 @@ func TestMutationResolver_CreateTeam(t *testing.T) { usersyncTrigger := make(chan<- uuid.UUID) teamSlug := slug.Slug("some-slug") slackChannel := "#my-slack-channel" + const tenant = "example" const tenantDomain = "example.com" t.Run("create team with empty purpose", func(t *testing.T) { _, err := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, auditer). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, auditer, fake.NewFakeSlackClient()). Mutation(). CreateTeam(ctx, model.CreateTeamInput{ Slug: teamSlug, @@ -115,7 +117,7 @@ func TestMutationResolver_CreateTeam(t *testing.T) { auditLogger := auditlogger.NewAuditLoggerForTesting() returnedTeam, err := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditLogger, nil, psClient.Topic("topic-id"), log, nil, nil, nil, nil, nil, nil, nil, auditer). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditLogger, nil, psClient.Topic("topic-id"), log, nil, nil, nil, nil, nil, nil, nil, auditer, fake.NewFakeSlackClient()). Mutation(). CreateTeam(ctx, model.CreateTeamInput{ Slug: teamSlug, @@ -172,7 +174,7 @@ func TestMutationResolver_CreateTeam(t *testing.T) { auditLogger := auditlogger.NewAuditLoggerForTesting() returnedTeam, err := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditLogger, nil, psClient.Topic("topic-id"), log, nil, nil, nil, nil, nil, nil, nil, auditer). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditLogger, nil, psClient.Topic("topic-id"), log, nil, nil, nil, nil, nil, nil, nil, auditer, fake.NewFakeSlackClient()). Mutation().CreateTeam(saCtx, model.CreateTeamInput{ Slug: teamSlug, Purpose: " some purpose ", @@ -194,6 +196,7 @@ func TestMutationResolver_CreateTeam(t *testing.T) { } func TestMutationResolver_RequestTeamDeletion(t *testing.T) { + const tenant = "example" const tenantDomain = "example.com" log, _ := test.NewNullLogger() usersyncTrigger := make(chan<- uuid.UUID) @@ -204,7 +207,7 @@ func TestMutationResolver_RequestTeamDeletion(t *testing.T) { db := database.NewMockDatabase(t) resolver := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil, fake.NewFakeSlackClient()). Mutation() user := database.User{ @@ -245,7 +248,7 @@ func TestMutationResolver_RequestTeamDeletion(t *testing.T) { ctx = loader.NewLoaderContext(ctx, db) resolver := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditlogger.NewAuditLoggerForTesting(), nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil, fake.NewFakeSlackClient()). Mutation() key, err := resolver.RequestTeamDeletion(ctx, teamSlug) assert.Nil(t, key) @@ -306,7 +309,7 @@ func TestMutationResolver_RequestTeamDeletion(t *testing.T) { auditLogger := auditlogger.NewAuditLoggerForTesting() resolver := graph. - NewResolver(nil, nil, nil, nil, db, tenantDomain, usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, auditer). + NewResolver(nil, nil, nil, nil, db, tenant, tenantDomain, usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, auditer, fake.NewFakeSlackClient()). Mutation() returnedKey, err := resolver.RequestTeamDeletion(ctx, teamSlug) diff --git a/internal/graph/users_test.go b/internal/graph/users_test.go index 5d4f95d4b..7ced64820 100644 --- a/internal/graph/users_test.go +++ b/internal/graph/users_test.go @@ -12,6 +12,7 @@ import ( "github.com/nais/api/internal/database" "github.com/nais/api/internal/database/gensql" "github.com/nais/api/internal/graph" + "github.com/nais/api/internal/slack/fake" "github.com/sirupsen/logrus/hooks/test" ) @@ -22,7 +23,7 @@ func TestQueryResolver_Users(t *testing.T) { log, _ := test.NewNullLogger() usersyncTrigger := make(chan<- uuid.UUID) resolver := graph. - NewResolver(nil, nil, nil, nil, db, "example.com", usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil). + NewResolver(nil, nil, nil, nil, db, "example", "example.com", usersyncTrigger, auditLogger, nil, nil, log, nil, nil, nil, nil, nil, nil, nil, nil, fake.NewFakeSlackClient()). Query() t.Run("unauthenticated user", func(t *testing.T) { diff --git a/internal/slack/fake/fake.go b/internal/slack/fake/fake.go new file mode 100644 index 000000000..ad342ab66 --- /dev/null +++ b/internal/slack/fake/fake.go @@ -0,0 +1,26 @@ +package fake + +import ( + "context" + + "github.com/nais/api/internal/graph/model" + "github.com/slack-go/slack" +) + +type FakeSlackClient struct{} + +func NewFakeSlackClient() *FakeSlackClient { + return &FakeSlackClient{} +} + +func (f *FakeSlackClient) PostMessage(channelName string, msgOptions []slack.MsgOption) (string, string, error) { + return "", "", nil +} + +func (f *FakeSlackClient) PostFeedbackMessage(msgOptions []slack.MsgOption) (string, string, error) { + return "", "", nil +} + +func (f *FakeSlackClient) GetFeedbackMessageOptions(ctx context.Context, tenant string, input model.CreateFeedbackInput) []slack.MsgOption { + return nil +} diff --git a/internal/slack/message.go b/internal/slack/message.go new file mode 100644 index 000000000..ac9f63f29 --- /dev/null +++ b/internal/slack/message.go @@ -0,0 +1,54 @@ +package slack + +import ( + "context" + "fmt" + + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/model" + "github.com/slack-go/slack" +) + +func (s *Slack) GetFeedbackMessageOptions(ctx context.Context, tenant string, input model.CreateFeedbackInput) []slack.MsgOption { + user := "anonymous" + + if !input.Anonymous { + actor := authz.ActorFromContext(ctx) + if actor != nil { + user = actor.User.Identity() + } + } + + var headerText string + switch input.Type { + case model.FeedbackTypeBug: + headerText = ":bug: Bug report" + case model.FeedbackTypeChangeRequest: + headerText = ":bulb: Change request" + case model.FeedbackTypeOther: + headerText = ":speech_balloon: Other feedback" + case model.FeedbackTypeQuestion: + headerText = ":question: Question" + } + + blocks := []slack.Block{} + headerBlock := slack.NewHeaderBlock(slack.NewTextBlockObject("plain_text", headerText, false, false)) + + blocks = append(blocks, headerBlock) + + var userBlock *slack.SectionBlock + + if user != "" { + userBlock = slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*From:* %s", user), false, false), nil, nil) + blocks = append(blocks, userBlock) + } + + uriBlock := slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*URI:* %s", input.URI), false, false), nil, nil) + tenantBlock := slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Tenant:* %s", tenant), false, false), nil, nil) + textBlock := slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", input.Details, false, false), nil, nil) + blocks = append(blocks, uriBlock, tenantBlock, textBlock) + + return []slack.MsgOption{ + slack.MsgOptionBlocks(blocks...), + } +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go new file mode 100644 index 000000000..d768840d4 --- /dev/null +++ b/internal/slack/slack.go @@ -0,0 +1,41 @@ +package slack + +import ( + "context" + + "github.com/nais/api/internal/graph/model" + "github.com/slack-go/slack" +) + +// Slack is a client for sending messages to Slack +type Slack struct { + feedbackChannel string + client *slack.Client +} + +type SlackClient interface { + PostMessage(channelName string, msgOptions []slack.MsgOption) (string, string, error) + PostFeedbackMessage(msgOptions []slack.MsgOption) (string, string, error) + GetFeedbackMessageOptions(ctx context.Context, tenant string, input model.CreateFeedbackInput) []slack.MsgOption +} + +// New creates a new Slack client +func New(token string, feedbackChannel string) SlackClient { + return &Slack{ + client: slack.New(token), + feedbackChannel: feedbackChannel, + } +} + +func (s *Slack) PostFeedbackMessage(msgOptions []slack.MsgOption) (string, string, error) { + return s.PostMessage(s.feedbackChannel, msgOptions) +} + +// SendMessage sends a message to a Slack channel +func (s *Slack) PostMessage(channelName string, msgOptions []slack.MsgOption) (string, string, error) { + channelId, timestamp, err := s.client.PostMessage(channelName, msgOptions...) + if err != nil { + return "", "", err + } + return channelId, timestamp, nil +}