[MM-48031] Work template read API (#21661)

This commit is contained in:
Julien Tant
2022-11-29 14:53:03 -07:00
committed by GitHub
parent a240cd53eb
commit 3e928f57fb
20 changed files with 1356 additions and 1 deletions

View File

@@ -110,6 +110,20 @@ jobs:
cd mattermost-server
make app-layers
if [[ -n $(git status --porcelain) ]]; then echo "Please update the app layers using make app-layers"; exit 1; fi
check-generate-worktemplates:
docker:
- image: cimg/go:1.18
resource_class: medium
working_directory: /mnt/ramdisk
steps:
- attach_workspace:
at: /mnt/ramdisk
- run:
command: |
cd mattermost-server
make generate-worktemplates
if [[ -n $(git status --porcelain) ]]; then echo "Please update the worktemplates using make generate-worktemplates"; exit 1; fi
check-go-mod-tidy:
docker:
- image: cimg/go:1.18
@@ -501,6 +515,9 @@ workflows:
- check-app-layers:
requires:
- setup-multi-product-repositories
- check-generate-worktemplates:
requires:
- setup-multi-product-repositories
- check-store-layers:
requires:
- setup-multi-product-repositories
@@ -527,6 +544,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -541,6 +559,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -558,6 +577,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -576,6 +596,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -594,6 +615,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -608,6 +630,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -660,6 +683,9 @@ workflows:
- check-app-layers:
requires:
- setup-multi-product-repositories
- check-generate-worktemplates:
requires:
- setup-multi-product-repositories
- check-store-layers:
requires:
- setup-multi-product-repositories
@@ -690,6 +716,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -704,6 +731,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -721,6 +749,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -737,6 +766,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates
@@ -753,6 +783,7 @@ workflows:
- check-go-mod-tidy
- check-golangci-lint
- check-app-layers
- check-generate-worktemplates
- check-store-layers
- check-mocks
- check-email-templates

View File

@@ -349,6 +349,9 @@ telemetry-mocks: ## Creates mock files.
store-layers: ## Generate layers for the store
$(GO) generate $(GOFLAGS) ./store
generate-worktemplates: ## Generate work templates
$(GO) generate $(GOFLAGS) ./app/worktemplates
new-migration: ## Creates a new migration. Run with make new-migration name=<>
$(GO) install github.com/mattermost/morph/cmd/morph@master
@echo "Generating new migration for mysql"

View File

@@ -140,6 +140,8 @@ type Routes struct {
Usage *mux.Router // 'api/v4/usage'
WorkTemplates *mux.Router // 'api/v4/worktemplates'
HostedCustomer *mux.Router // 'api/v4/hosted_customer'
Drafts *mux.Router // 'api/v4/drafts'
@@ -269,6 +271,8 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter()
api.BaseRoutes.WorkTemplates = api.BaseRoutes.APIRoot.PathPrefix("/worktemplates").Subrouter()
api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter()
api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter()
@@ -316,6 +320,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitExport()
api.InitInsights()
api.InitUsage()
api.InitWorkTemplate()
api.InitHostedCustomer()
api.InitDrafts()
if err := api.InitGraphQL(); err != nil {

67
api4/worktemplates.go Normal file
View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost-server/v6/model"
)
func (api *API) InitWorkTemplate() {
api.BaseRoutes.WorkTemplates.Handle("/categories", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplateCategories))).Methods("GET")
api.BaseRoutes.WorkTemplates.Handle("/categories/{category}/templates", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplates))).Methods("GET")
}
func needsWorkTemplateFeatureFlag(h handlerFunc) handlerFunc {
return func(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.Config().FeatureFlags.WorkTemplate {
http.NotFound(w, r)
return
}
h(c, w, r)
}
}
func getWorkTemplateCategories(c *Context, w http.ResponseWriter, r *http.Request) {
t := c.AppContext.GetT()
categories, appErr := c.App.GetWorkTemplateCategories(t)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("getWorkTemplateCategories", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError)
return
}
w.Write(b)
}
func getWorkTemplates(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCategory()
if c.Err != nil {
return
}
t := c.AppContext.GetT()
workTemplates, appErr := c.App.GetWorkTemplates(c.Params.Category, c.App.Config().FeatureFlags.ToMap(), t)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(workTemplates)
if err != nil {
c.Err = model.NewAppError("getWorkTemplates", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError)
return
}
w.Write(b)
}

110
api4/worktemplates_test.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"os"
"testing"
"github.com/mattermost/mattermost-server/v6/app/worktemplates"
"github.com/stretchr/testify/require"
)
func TestWorkTemplateCategories(t *testing.T) {
// Setup
cleanup := setupWorktemplateFeatureFlag(t)
defer cleanup()
th := Setup(t).InitBasic()
defer th.TearDown()
assert := require.New(t)
worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{
{
ID: "test-category",
Name: "Test Category",
},
{
ID: "test-category-2",
Name: "Test Category 2",
},
}
// Act
categories, _, clientErr := th.Client.GetWorktemplateCategories()
// Assert
require.NoError(t, clientErr)
require.Len(t, categories, 2)
assert.Equal("test-category", categories[0].ID)
assert.Equal("test-category-2", categories[1].ID)
}
func TestGetWorkTemplatesByCategory(t *testing.T) {
// Setup
cleanup := setupWorktemplateFeatureFlag(t)
defer cleanup()
th := Setup(t).InitBasic()
defer th.TearDown()
assert := require.New(t)
worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{
{
ID: "test-category",
Name: "Test Category",
},
{
ID: "test-category-2",
Name: "Test Category 2",
},
}
worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{
{
ID: "test-template",
Category: "test-category",
UseCase: "Test Template",
},
{
ID: "test-template-2",
Category: "test-category",
UseCase: "Test Template 2",
},
{ // This one should not be returned because of the feature flag
ID: "test-template-3",
Category: "test-category",
UseCase: "Test Template 3",
FeatureFlag: &worktemplates.FeatureFlag{
Name: "random-nonexistant-feature-flag",
Value: "true",
},
},
{ // this one should not be returned because of the category
ID: "test-template-4",
Category: "test-category-2",
UseCase: "Test Template 4",
},
}
// Act
workTemplates, _, clientErr := th.Client.GetWorkTemplatesByCategory("test-category")
// Assert
assert.NoError(clientErr, "error while retrieve worktemplates list")
assert.Len(workTemplates, 2)
assert.Equal("test-template", workTemplates[0].ID)
assert.Equal("test-template-2", workTemplates[1].ID)
}
func setupWorktemplateFeatureFlag(t *testing.T) func() {
t.Helper()
oldFFValue := os.Getenv("MM_FEATUREFLAGS_WORKTEMPLATE")
os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", "true")
return func() {
os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", oldFFValue)
}
}

View File

@@ -850,6 +850,8 @@ type AppIface interface {
GetViewUsersRestrictions(userID string) (*model.ViewUsersRestrictions, *model.AppError)
GetWarnMetricsBot() (*model.Bot, *model.AppError)
GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError)
GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError)
GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError)
HTTPService() httpservice.HTTPService
Handle404(w http.ResponseWriter, r *http.Request)
HandleCommandResponse(c request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError)

View File

@@ -11214,6 +11214,50 @@ func (a *OpenTracingAppLayer) GetWarnMetricsStatus() (map[string]*model.WarnMetr
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplateCategories")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.GetWorkTemplateCategories(t)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplates")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.GetWorkTemplates(category, featureFlags, t)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) Handle404(w http.ResponseWriter, r *http.Request) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Handle404")

52
app/worktemplates.go Normal file
View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost-server/v6/app/worktemplates"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/i18n"
)
func (a *App) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) {
categories, err := worktemplates.ListCategories()
if err != nil {
return nil, model.NewAppError("GetWorkTemplateCategories", "app.worktemplates.get_categories.app_error", nil, err.Error(), http.StatusInternalServerError)
}
modelCategories := make([]*model.WorkTemplateCategory, len(categories))
for i := range categories {
modelCategories[i] = &model.WorkTemplateCategory{
ID: categories[i].ID,
Name: t(categories[i].Name),
}
}
return modelCategories, nil
}
func (a *App) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) {
templates, err := worktemplates.ListByCategory(category)
if err != nil {
return nil, model.NewAppError("GetWorkTemplates", "app.worktemplates.get_templates.app_error", nil, err.Error(), http.StatusInternalServerError)
}
// filter out templates that are not enabled by feature Flag
enabledTemplates := []*model.WorkTemplate{}
for _, template := range templates {
mTemplate := template.ToModelWorkTemplate(t)
if template.FeatureFlag == nil {
enabledTemplates = append(enabledTemplates, mTemplate)
continue
}
if featureFlags[template.FeatureFlag.Name] == template.FeatureFlag.Value {
enabledTemplates = append(enabledTemplates, mTemplate)
}
}
return enabledTemplates, nil
}

View File

@@ -0,0 +1,2 @@
- id: product_teams
name: worktemplate.category.product_teams

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"bytes"
"crypto/md5"
_ "embed"
"fmt"
"html/template"
"io"
"log"
"os"
"path"
"github.com/mattermost/mattermost-server/v6/app/worktemplates"
"github.com/pkg/errors"
"golang.org/x/tools/imports"
"gopkg.in/yaml.v3"
)
type WorkTemplateWithMD5 struct {
worktemplates.WorkTemplate
MD5 string
}
type WorkTemplateCategoryWithMD5 struct {
worktemplates.WorkTemplateCategory `yaml:",inline"`
MD5 string
}
func getFileContent(filename string) ([]byte, error) {
return os.ReadFile(path.Join(filename))
}
func main() {
// parse categories first
dat, err := getFileContent("categories.yaml")
if err != nil {
log.Fatal(errors.Wrap(err, "failed to read categories.yaml"))
}
h := md5.New()
cats := []WorkTemplateCategoryWithMD5{} // meow
err = yaml.Unmarshal(dat, &cats)
if err != nil {
log.Fatal(errors.Wrap(err, "failed to unmarshal categories.yaml"))
}
// validate categories
categoryIds := map[string]struct{}{}
for id := range cats {
cat := cats[id]
if cat.ID == "" && cat.Name == "" {
// skip empty array element
continue
}
if cat.ID == "" {
log.Fatal(errors.New("category ID cannot be empty"))
}
if cat.Name == "" {
log.Fatal(errors.New("category name cannot be empty"))
}
categoryIds[cat.ID] = struct{}{}
h.Write([]byte(cat.ID))
cats[id].MD5 = fmt.Sprintf("%x", h.Sum(nil))
h.Reset()
}
dat, err = getFileContent("templates.yaml")
if err != nil {
log.Fatal(errors.Wrap(err, "failed to read templates.yaml"))
}
dec := yaml.NewDecoder(bytes.NewReader(dat))
ts := []WorkTemplateWithMD5{}
for {
t := worktemplates.WorkTemplate{}
err = dec.Decode(&t)
if err != nil {
if err == io.EOF {
break
}
log.Fatal(err)
}
if t.ID == "" {
continue
}
h.Write([]byte(t.ID))
err = t.Validate(categoryIds)
if err != nil {
log.Fatal(errors.Wrap(err, "failed to validate template"))
}
ts = append(ts, WorkTemplateWithMD5{
WorkTemplate: t,
MD5: fmt.Sprintf("%x", h.Sum(nil)),
})
h.Reset()
}
code := bytes.NewBuffer(nil)
tmpl, err := template.New("worktemplates").Parse(tpl)
if err != nil {
log.Fatal(err)
}
tmpl.Execute(code, struct {
Templates []WorkTemplateWithMD5
Categories []WorkTemplateCategoryWithMD5
}{
Templates: ts,
Categories: cats,
})
formattedCode, err := imports.Process(path.Join("worktemplate_generated.go"), code.Bytes(), &imports.Options{Comments: true})
if err != nil {
log.Fatal(errors.Wrap(err, "failed to format code"))
}
err = os.WriteFile(path.Join("worktemplate_generated.go"), formattedCode, 0644)
if err != nil {
log.Fatal(err)
}
// print all translatable content
fmt.Println("\nTranslation helpers:\n====================")
for _, t := range ts {
translationHelper(t.Description.Channel)
translationHelper(t.Description.Board)
translationHelper(t.Description.Playbook)
translationHelper(t.Description.Integration)
}
}
var translationHelperTemplate = `{
"id": %q,
"translation": %q
},`
func translationHelper(t *worktemplates.TranslatableString) {
if t != nil && t.ID != "" && t.DefaultMessage != "" {
fmt.Printf(translationHelperTemplate, t.ID, t.DefaultMessage)
fmt.Println("")
}
}
//go:embed worktemplate.tmpl
var tpl string

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make generate-worktemplates"
// DO NOT EDIT
package worktemplates
func init() {
{{- range .Categories}}
registerWorkTemplateCategory("{{.ID}}", wtc{{.MD5}})
{{- end -}}
{{range .Templates}}
registerWorkTemplate("{{.ID}}", wt{{.MD5}})
{{- end}}
// Register categories strings
{{range .Categories -}}
_ = T("{{.Name}}")
{{end}}
// Register translation strings
{{range .Templates -}}
{{if and (.Description.Channel) (ne .Description.Channel.ID "")}}_ = T("{{.Description.Channel.ID}}")
{{end -}}
{{if and (.Description.Board) (ne .Description.Board.ID "")}}_ = T("{{.Description.Board.ID}}")
{{end -}}
{{if and (.Description.Playbook) (ne .Description.Playbook.ID "")}}_ = T("{{.Description.Playbook.ID}}")
{{end -}}
{{if and (.Description.Integration) (ne .Description.Integration.ID "")}}_ = T("{{.Description.Integration.ID}}")
{{end -}}
{{end -}}
}
{{range .Categories}}
var wtc{{.MD5}} = &WorkTemplateCategory{
ID: "{{.ID}}",
Name: "{{.Name}}",
}
{{end}}
{{range .Templates}}
var wt{{.MD5}} = &WorkTemplate{
ID: "{{.ID}}",
Category: "{{.Category}}",
UseCase: "{{.UseCase}}",
Illustration: "{{.Illustration}}",
Visibility: "{{.Visibility}}",
{{if .FeatureFlag}}FeatureFlag: &FeatureFlag{
Name: "{{.FeatureFlag.Name}}",
Value: "{{.FeatureFlag.Value}}",
},{{end}}
Description: Description{
{{if .Description.Channel}}Channel: &TranslatableString{
ID: "{{.Description.Channel.ID}}",
DefaultMessage: "{{.Description.Channel.DefaultMessage}}",
Illustration: "{{.Description.Channel.Illustration}}",
},{{end}}
{{if .Description.Board}}Board: &TranslatableString{
ID: "{{.Description.Board.ID}}",
DefaultMessage: "{{.Description.Board.DefaultMessage}}",
Illustration: "{{.Description.Board.Illustration}}",
},{{end}}
{{if .Description.Playbook}}Playbook: &TranslatableString{
ID: "{{.Description.Playbook.ID}}",
DefaultMessage: "{{.Description.Playbook.DefaultMessage}}",
Illustration: "{{.Description.Playbook.Illustration}}",
},{{end}}
{{if .Description.Integration}}Integration: &TranslatableString{
ID: "{{.Description.Integration.ID}}",
DefaultMessage: "{{.Description.Integration.DefaultMessage}}",
Illustration: "{{.Description.Integration.Illustration}}",
},{{end}}
},
Content: []Content{
{{range .Content}}{
{{if .Channel}}Channel: &Channel{
ID: "{{.Channel.ID}}",
Name: "{{.Channel.Name}}",
Purpose: "{{.Channel.Purpose}}",
Playbook: "{{.Channel.Playbook}}",
Illustration: "{{.Channel.Illustration}}",
},{{end}}{{if .Board}}Board: &Board{
ID: "{{.Board.ID}}",
Template: "{{.Board.Template}}",
Name: "{{.Board.Name}}",
Channel: "{{.Board.Channel}}",
Illustration: "{{.Board.Illustration}}",
},{{end}}{{if .Playbook}}Playbook: &Playbook{
Template: "{{.Playbook.Template}}",
Name: "{{.Playbook.Name}}",
ID: "{{.Playbook.ID}}",
Illustration: "{{.Playbook.Illustration}}",
},{{end}}{{if .Integration}}Integration: &Integration{
ID: "{{.Integration.ID}}",
},{{end}}
},
{{end}}
},
}
{{end}}

View File

@@ -0,0 +1,46 @@
id: "product_teams/feature_release:v1"
category: product_teams
useCase: Feature Release
illustration: https://via.placeholder.com/204x123.png
visibility: public
description:
channel:
id: "worktemplate.product_teams.feature_release.description.channel"
defaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots."
board:
id: "worktemplate.product_teams.feature_release.description.board"
defaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way."
playbook:
id: "worktemplate.product_teams.feature_release.description.playbook"
defaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless."
integration:
id: "worktemplate.product_teams.feature_release.description.integration"
defaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you."
illustration: "https://via.placeholder.com/509x352.png?text=Integrations"
content:
- channel:
id: feature-release
name: Feature Release
playbook: product-release-playbook # playbook id. if set the channel will be created by the playbook run.
illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release"
- board:
id: "board-meeting-agenda"
template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o" # <-- have to find a way to target the board template... could hardcode the ids but need to verify that they don't change?
name: Meeting Agenda
channel: feature-release # <-- optional. we use the channel "id" from above
illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda"
- board:
id: "board-project-task"
template: "project task|bmttiziw35irgtmztewd9upyqdy"
name: project task board
channel: feature-release
illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task"
- playbook:
template: "product release" # <-- playbooks templates don't have ids, have to rely on name
name: "Feature release"
id: product-release-playbook
illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release"
- integration:
id: jira
- integration:
id: github

342
app/worktemplates/types.go Normal file
View File

@@ -0,0 +1,342 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package worktemplates
import (
"fmt"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/i18n"
"github.com/pkg/errors"
)
type WorkTemplateCategory struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
}
type WorkTemplate struct {
ID string `yaml:"id"`
Category string `yaml:"category"`
UseCase string `yaml:"useCase"`
Illustration string `yaml:"illustration"`
Visibility string `yaml:"visibility"`
FeatureFlag *FeatureFlag `yaml:"featureFlag,omitempty"`
Description Description `yaml:"description"`
Content []Content `yaml:"content"`
}
func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemplate {
mwt := &model.WorkTemplate{
ID: wt.ID,
Category: wt.Category,
UseCase: wt.UseCase,
Illustration: wt.Illustration,
Visibility: wt.Visibility,
}
if wt.FeatureFlag != nil {
mwt.FeatureFlag = &model.WorkTemplateFeatureFlag{
Name: wt.FeatureFlag.Name,
Value: wt.FeatureFlag.Value,
}
}
if wt.Description.Channel != nil {
mwt.Description.Channel = &model.DescriptionContent{
Message: wt.Description.Channel.Translate(t),
Illustration: wt.Description.Channel.Illustration,
}
}
if wt.Description.Board != nil {
mwt.Description.Board = &model.DescriptionContent{
Message: wt.Description.Board.Translate(t),
Illustration: wt.Description.Board.Illustration,
}
}
if wt.Description.Playbook != nil {
mwt.Description.Playbook = &model.DescriptionContent{
Message: wt.Description.Playbook.Translate(t),
Illustration: wt.Description.Playbook.Illustration,
}
}
if wt.Description.Integration != nil {
mwt.Description.Integration = &model.DescriptionContent{
Message: wt.Description.Integration.Translate(t),
Illustration: wt.Description.Integration.Illustration,
}
}
for _, content := range wt.Content {
if content.Channel != nil {
mwt.Content = append(mwt.Content, model.WorkTemplateContent{
Channel: &model.WorkTemplateChannel{
ID: content.Channel.ID,
Name: content.Channel.Name,
Purpose: content.Channel.Purpose,
Playbook: content.Channel.Playbook,
Illustration: content.Channel.Illustration,
},
})
}
if content.Board != nil {
mwt.Content = append(mwt.Content, model.WorkTemplateContent{
Board: &model.WorkTemplateBoard{
ID: content.Board.ID,
Name: content.Board.Name,
Template: content.Board.Template,
Channel: content.Board.Channel,
Illustration: content.Board.Illustration,
},
})
}
if content.Playbook != nil {
mwt.Content = append(mwt.Content, model.WorkTemplateContent{
Playbook: &model.WorkTemplatePlaybook{
ID: content.Playbook.ID,
Name: content.Playbook.Name,
Template: content.Playbook.Template,
Illustration: content.Playbook.Illustration,
},
})
}
if content.Integration != nil {
mwt.Content = append(mwt.Content, model.WorkTemplateContent{
Integration: &model.WorkTemplateIntegration{
ID: content.Integration.ID,
},
})
}
}
return mwt
}
func (wt WorkTemplate) Validate(categoryIds map[string]struct{}) error {
if wt.ID == "" {
return errors.New("id is required")
}
if wt.Category == "" {
return errors.New("category is required")
}
if _, ok := categoryIds[wt.Category]; !ok {
return fmt.Errorf("category %s does not exist", wt.Category)
}
if wt.UseCase == "" {
return errors.New("useCase is required")
}
if wt.Illustration == "" {
return errors.New("illustration is required")
}
if wt.Visibility == "" {
return errors.New("visibility is required")
}
hasChannel := false
hasBoard := false
hasPlaybook := false
hasIntegration := false
foundChannels := map[string]struct{}{}
foundPlaybooks := map[string]struct{}{}
foundBoards := map[string]struct{}{}
foundIntegrations := map[string]struct{}{}
mustHaveChannels := []string{}
mustHavePlaybooks := []string{}
currentIdx := 0
for _, content := range wt.Content {
if content.Channel != nil {
hasChannel = true
if cErr := content.Channel.Validate(); cErr != nil {
return wrapContentError(cErr, currentIdx)
}
if _, ok := foundChannels[content.Channel.ID]; ok {
return wrapContentError(fmt.Errorf("duplicate channel %s found", content.Channel.ID), currentIdx)
}
foundChannels[content.Channel.ID] = struct{}{}
if content.Channel.Playbook != "" {
mustHavePlaybooks = append(mustHavePlaybooks, content.Channel.Playbook)
}
}
if content.Board != nil {
hasBoard = true
if cErr := content.Board.Validate(); cErr != nil {
return wrapContentError(cErr, currentIdx)
}
if _, ok := foundBoards[content.Board.ID]; ok {
return wrapContentError(fmt.Errorf("duplicate board %s found", content.Board.ID), currentIdx)
}
foundBoards[content.Board.ID] = struct{}{}
if content.Board.Channel != "" {
mustHaveChannels = append(mustHaveChannels, content.Board.Channel)
}
}
if content.Playbook != nil {
hasPlaybook = true
if cErr := content.Playbook.Validate(); cErr != nil {
return wrapContentError(cErr, currentIdx)
}
if _, ok := foundPlaybooks[content.Playbook.ID]; ok {
return wrapContentError(fmt.Errorf("duplicate playbook %s found", content.Playbook.ID), currentIdx)
}
foundPlaybooks[content.Playbook.ID] = struct{}{}
}
if content.Integration != nil {
hasIntegration = true
if cErr := content.Integration.Validate(); cErr != nil {
return wrapContentError(cErr, currentIdx)
}
if _, ok := foundIntegrations[content.Integration.ID]; ok {
return wrapContentError(fmt.Errorf("duplicate integration %s found", content.Integration.ID), currentIdx)
}
foundIntegrations[content.Integration.ID] = struct{}{}
}
}
if hasChannel && wt.Description.Channel == nil {
return errors.New("description.channel is required")
}
if hasBoard && wt.Description.Board == nil {
return errors.New("description.board is required")
}
if hasPlaybook && wt.Description.Playbook == nil {
return errors.New("description.playbook is required")
}
if hasIntegration && wt.Description.Integration == nil {
return errors.New("description.integration is required")
}
for _, channel := range mustHaveChannels {
if _, ok := foundChannels[channel]; !ok {
return fmt.Errorf("channel %s is required", channel)
}
}
for _, playbook := range mustHavePlaybooks {
if _, ok := foundPlaybooks[playbook]; !ok {
return fmt.Errorf("playbook %s is required", playbook)
}
}
return nil
}
type FeatureFlag struct {
Name string `json:"name"`
Value string `json:"value"`
}
type TranslatableString struct {
ID string `yaml:"id"`
DefaultMessage string `yaml:"defaultMessage"`
Illustration string `yaml:"illustration"`
}
func (ts TranslatableString) Translate(t i18n.TranslateFunc) string {
if ts.ID != "" {
msg := t(ts.ID)
if msg != ts.ID && msg != "" {
return msg
}
}
return ts.DefaultMessage
}
type Description struct {
Channel *TranslatableString `yaml:"channel"`
Board *TranslatableString `yaml:"board"`
Playbook *TranslatableString `yaml:"playbook"`
Integration *TranslatableString `yaml:"integration"`
}
type Channel struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Purpose string `yaml:"purpose"`
Playbook string `yaml:"playbook"`
Illustration string `yaml:"illustration"`
}
func (c *Channel) Validate() error {
if c.ID == "" {
return errors.New("id is required")
}
if c.Name == "" {
return errors.New("name is required")
}
return nil
}
type Board struct {
ID string `yaml:"id"`
Template string `yaml:"template"`
Name string `yaml:"name"`
Channel string `yaml:"channel"`
Illustration string `yaml:"illustration"`
}
func (b Board) Validate() error {
if b.ID == "" {
return errors.New("id is required")
}
if b.Template == "" {
return errors.New("template is required")
}
if b.Name == "" {
return errors.New("name is required")
}
return nil
}
type Playbook struct {
Template string `yaml:"template"`
Name string `yaml:"name"`
ID string `yaml:"id"`
Illustration string `yaml:"illustration"`
}
func (p *Playbook) Validate() error {
if p.ID == "" {
return errors.New("id is required")
}
if p.Template == "" {
return errors.New("template is required")
}
if p.Name == "" {
return errors.New("name is required")
}
return nil
}
type Integration struct {
ID string `yaml:"id"`
}
func (i *Integration) Validate() error {
if i.ID == "" {
return errors.New("id is required")
}
return nil
}
type Content struct {
Channel *Channel `yaml:"channel,omitempty"`
Board *Board `yaml:"board,omitempty"`
Playbook *Playbook `yaml:"playbook,omitempty"`
Integration *Integration `yaml:"integration,omitempty"`
}
func wrapContentError(err error, index int) error {
return errors.Wrapf(err, "content #%d validation failed", index)
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make generate-worktemplates"
// DO NOT EDIT
package worktemplates
func init() {
registerWorkTemplateCategory("product_teams", wtc846b565cd80043537945134a54812e07)
registerWorkTemplate("product_teams/feature_release:v1", wt00a1b44a5831c0a3acb14787b3fdd352)
// Register categories strings
_ = T("worktemplate.category.product_teams")
// Register translation strings
_ = T("worktemplate.product_teams.feature_release.description.channel")
_ = T("worktemplate.product_teams.feature_release.description.board")
_ = T("worktemplate.product_teams.feature_release.description.playbook")
_ = T("worktemplate.product_teams.feature_release.description.integration")
}
var wtc846b565cd80043537945134a54812e07 = &WorkTemplateCategory{
ID: "product_teams",
Name: "worktemplate.category.product_teams",
}
var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{
ID: "product_teams/feature_release:v1",
Category: "product_teams",
UseCase: "Feature Release",
Illustration: "https://via.placeholder.com/204x123.png",
Visibility: "public",
Description: Description{
Channel: &TranslatableString{
ID: "worktemplate.product_teams.feature_release.description.channel",
DefaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots.",
Illustration: "",
},
Board: &TranslatableString{
ID: "worktemplate.product_teams.feature_release.description.board",
DefaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way.",
Illustration: "",
},
Playbook: &TranslatableString{
ID: "worktemplate.product_teams.feature_release.description.playbook",
DefaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless.",
Illustration: "",
},
Integration: &TranslatableString{
ID: "worktemplate.product_teams.feature_release.description.integration",
DefaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you.",
Illustration: "https://via.placeholder.com/509x352.png?text=Integrations",
},
},
Content: []Content{
{
Channel: &Channel{
ID: "feature-release",
Name: "Feature Release",
Purpose: "",
Playbook: "product-release-playbook",
Illustration: "https://via.placeholder.com/509x352.png?text=Channel&#43;feature&#43;release",
},
},
{
Board: &Board{
ID: "board-meeting-agenda",
Template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o",
Name: "Meeting Agenda",
Channel: "feature-release",
Illustration: "https://via.placeholder.com/509x352.png?text=Board&#43;meeting&#43;agenda",
},
},
{
Board: &Board{
ID: "board-project-task",
Template: "project task|bmttiziw35irgtmztewd9upyqdy",
Name: "project task board",
Channel: "feature-release",
Illustration: "https://via.placeholder.com/509x352.png?text=Board&#43;project&#43;task",
},
},
{
Playbook: &Playbook{
Template: "product release",
Name: "Feature release",
ID: "product-release-playbook",
Illustration: "https://via.placeholder.com/509x352.png?text=Playbook&#43;feature&#43;release",
},
},
{
Integration: &Integration{
ID: "jira",
},
},
{
Integration: &Integration{
ID: "github",
},
},
},
}

View File

@@ -0,0 +1,37 @@
//go:generate go run generator/main.go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package worktemplates
var OrderedWorkTemplates = []*WorkTemplate{}
var OrderedWorkTemplateCategories = []*WorkTemplateCategory{}
// T is a placeholder to allow the translation tool to register the strings
func T(id string) string {
return id
}
func registerWorkTemplate(id string, wt *WorkTemplate) {
OrderedWorkTemplates = append(OrderedWorkTemplates, wt)
}
func registerWorkTemplateCategory(id string, wtc *WorkTemplateCategory) {
OrderedWorkTemplateCategories = append(OrderedWorkTemplateCategories, wtc)
}
func ListCategories() ([]*WorkTemplateCategory, error) {
return OrderedWorkTemplateCategories, nil
}
func ListByCategory(category string) ([]*WorkTemplate, error) {
wts := []*WorkTemplate{}
for i := range OrderedWorkTemplates {
if OrderedWorkTemplates[i].Category == category {
wts = append(wts, OrderedWorkTemplates[i])
}
}
return wts, nil
}

125
app/worktemplates_test.go Normal file
View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/app/worktemplates"
)
func TestGetWorkTemplateCategories(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
assert := require.New(t)
worktemplates.OrderedWorkTemplateCategories = wtGetCategories()
categories, appErr := th.App.GetWorkTemplateCategories(wtTranslationFunc)
assert.Nil(appErr)
assert.Len(categories, 2)
assert.Equal("Translated test.1", categories[0].Name)
assert.Equal("Translated test.2", categories[1].Name)
}
func TestGetWorkTemplatesByCategory(t *testing.T) {
// Setup
th := SetupWithStoreMock(t)
defer th.TearDown()
assert := require.New(t)
existingFFkey := "test-feature-flag"
existingFFvalue := "true"
ff := map[string]string{
existingFFkey: existingFFvalue,
}
worktemplates.OrderedWorkTemplateCategories = wtGetCategories()
firstCat := worktemplates.OrderedWorkTemplateCategories[0]
worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{
{
ID: "test-template",
Category: firstCat.ID,
UseCase: "test use case",
Description: worktemplates.Description{
Channel: &worktemplates.TranslatableString{
ID: "test-template-channel-description",
DefaultMessage: "test template channel description",
},
},
},
{ // this one should not be returned because of the FF
ID: "test-template-2",
Category: firstCat.ID,
UseCase: "test use case 2",
FeatureFlag: &worktemplates.FeatureFlag{
Name: "nonexistant-random-test-feature-flag",
Value: "hi",
},
Description: worktemplates.Description{
Channel: &worktemplates.TranslatableString{
ID: "test-template-2-channel-description",
DefaultMessage: "test template 2 channel description",
},
},
},
{ // this one should be present and match the FF
ID: "test-template-3",
Category: firstCat.ID,
UseCase: "test use case 3",
FeatureFlag: &worktemplates.FeatureFlag{
Name: existingFFkey,
Value: existingFFvalue,
},
Description: worktemplates.Description{
Channel: &worktemplates.TranslatableString{
ID: "unknown", // simulating an unknown translation, we return the default message in this case
DefaultMessage: "default message picked for unknown",
},
},
},
{ // this one should not be returned because of the category
ID: "test-template-4",
Category: "cat-test2",
UseCase: "test use case 4",
},
}
// Act
worktemplates, appErr := th.App.GetWorkTemplates(firstCat.ID, ff, wtTranslationFunc)
// Assert
assert.Nil(appErr)
assert.Len(worktemplates, 2)
// assert the correct work templates have been returned
assert.Equal("test-template", worktemplates[0].ID)
assert.Equal("test-template-3", worktemplates[1].ID)
// assert the descriptions have been translated
assert.Equal("Translated test-template-channel-description", worktemplates[0].Description.Channel.Message)
assert.Equal("default message picked for unknown", worktemplates[1].Description.Channel.Message)
}
// helpers
func wtTranslationFunc(id string, args ...interface{}) string {
if id == "unknown" {
return ""
}
return "Translated " + id
}
func wtGetCategories() []*worktemplates.WorkTemplateCategory {
return []*worktemplates.WorkTemplateCategory{
{
ID: "cat-test1",
Name: "test.1",
},
{
ID: "cat-test2",
Name: "test.2",
},
}
}

2
go.mod
View File

@@ -70,6 +70,7 @@ require (
golang.org/x/tools v0.3.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -178,7 +179,6 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.1.1 // indirect
modernc.org/cc/v3 v3.36.0 // indirect
modernc.org/ccgo/v3 v3.16.6 // indirect

View File

@@ -6991,6 +6991,14 @@
"id": "app.webhooks.update_outgoing.app_error",
"translation": "Unable to update the webhook."
},
{
"id": "app.worktemplates.get_categories.app_error",
"translation": "Unable to get work template categories"
},
{
"id": "app.worktemplates.get_templates.app_error",
"translation": "Unable to get work templates"
},
{
"id": "bleveengine.already_started.error",
"translation": "Bleve is already started."
@@ -9686,5 +9694,25 @@
{
"id": "web.incoming_webhook.user.app_error",
"translation": "Couldn't find the user."
},
{
"id": "worktemplate.category.product_teams",
"translation": "Product Teams"
},
{
"id": "worktemplate.product_teams.feature_release.description.board",
"translation": "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way."
},
{
"id": "worktemplate.product_teams.feature_release.description.channel",
"translation": "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots."
},
{
"id": "worktemplate.product_teams.feature_release.description.integration",
"translation": "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you."
},
{
"id": "worktemplate.product_teams.feature_release.description.playbook",
"translation": "Create transparent workflows across development teams to ensure your feature development process is seamless."
}
]

View File

@@ -8557,3 +8557,34 @@ func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) {
defer closeBody(r)
return BuildResponse(r), nil
}
// Worktemplates sections
func (c *Client4) worktemplatesRoute() string {
return "/worktemplates"
}
// GetWorktemplateCategories returns categories of worktemplates
func (c *Client4) GetWorktemplateCategories() ([]*WorkTemplateCategory, *Response, error) {
r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories", "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var categories []*WorkTemplateCategory
err = json.NewDecoder(r.Body).Decode(&categories)
return categories, BuildResponse(r), err
}
func (c *Client4) GetWorkTemplatesByCategory(category string) ([]*WorkTemplate, *Response, error) {
r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories/"+category+"/templates", "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var templates []*WorkTemplate
err = json.NewDecoder(r.Body).Decode(&templates)
return templates, BuildResponse(r), err
}

71
model/worktemplate.go Normal file
View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type WorkTemplateCategory struct {
ID string `json:"id"`
Name string `json:"name"`
}
type WorkTemplate struct {
ID string `json:"id"`
Category string `json:"category"`
UseCase string `json:"useCase"`
Illustration string `json:"illustration"`
Visibility string `json:"visibility"`
FeatureFlag *WorkTemplateFeatureFlag `json:"featureFlag,omitempty"`
Description Description `json:"description"`
Content []WorkTemplateContent `json:"content"`
}
type WorkTemplateFeatureFlag struct {
Name string `json:"name"`
Value string `json:"value"`
}
type DescriptionContent struct {
Message string `json:"message"`
Illustration string `json:"illustration"`
}
type Description struct {
Channel *DescriptionContent `json:"channel"`
Board *DescriptionContent `json:"board"`
Playbook *DescriptionContent `json:"playbook"`
Integration *DescriptionContent `json:"integration"`
}
type WorkTemplateChannel struct {
ID string `json:"id"`
Name string `json:"name"`
Purpose string `json:"purpose"`
Playbook string `json:"playbook"`
Illustration string `json:"illustration"`
}
type WorkTemplateBoard struct {
ID string `json:"id"`
Template string `json:"template"`
Name string `json:"name"`
Channel string `json:"channel"`
Illustration string `json:"illustration"`
}
type WorkTemplatePlaybook struct {
Template string `json:"template"`
Name string `json:"name"`
ID string `json:"id"`
Illustration string `json:"illustration"`
}
type WorkTemplateIntegration struct {
ID string `json:"id"`
}
type WorkTemplateContent struct {
Channel *WorkTemplateChannel `json:"channel,omitempty"`
Board *WorkTemplateBoard `json:"board,omitempty"`
Playbook *WorkTemplatePlaybook `json:"playbook,omitempty"`
Integration *WorkTemplateIntegration `json:"integration,omitempty"`
}