diff --git a/backend/plugins/teambition/api/blueprint_v200.go b/backend/plugins/teambition/api/blueprint_v200.go index c71d5b644fc..e85a3dd4627 100644 --- a/backend/plugins/teambition/api/blueprint_v200.go +++ b/backend/plugins/teambition/api/blueprint_v200.go @@ -53,7 +53,7 @@ func MakeDataSourcePipelinePlanV200( func makePipelinePlanV200( subtaskMetas []plugin.SubTaskMeta, - scopeDetails []*srvhelper.ScopeDetail[models.TeambitionProject, srvhelper.NoScopeConfig], + scopeDetails []*srvhelper.ScopeDetail[models.TeambitionProject, models.TeambitionScopeConfig], connection *models.TeambitionConnection, ) (coreModels.PipelinePlan, errors.Error) { plan := make(coreModels.PipelinePlan, len(scopeDetails)) @@ -85,7 +85,7 @@ func makePipelinePlanV200( } func makeScopesV200( - scopeDetails []*srvhelper.ScopeDetail[models.TeambitionProject, srvhelper.NoScopeConfig], + scopeDetails []*srvhelper.ScopeDetail[models.TeambitionProject, models.TeambitionScopeConfig], connection *models.TeambitionConnection, ) ([]plugin.Scope, errors.Error) { scopes := make([]plugin.Scope, 0, len(scopeDetails)) diff --git a/backend/plugins/teambition/api/init.go b/backend/plugins/teambition/api/init.go index f1e3e0c8b20..d429405afbf 100644 --- a/backend/plugins/teambition/api/init.go +++ b/backend/plugins/teambition/api/init.go @@ -21,14 +21,16 @@ import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" - "github.com/apache/incubator-devlake/helpers/srvhelper" "github.com/apache/incubator-devlake/plugins/teambition/models" "github.com/go-playground/validator/v10" ) var vld *validator.Validate -var dsHelper *api.DsHelper[models.TeambitionConnection, models.TeambitionProject, srvhelper.NoScopeConfig] +var dsHelper *api.DsHelper[models.TeambitionConnection, models.TeambitionProject, models.TeambitionScopeConfig] var basicRes context.BasicRes +var raProxy *api.DsRemoteApiProxyHelper[models.TeambitionConnection] +var raScopeList *api.DsRemoteApiScopeListHelper[models.TeambitionConnection, models.TeambitionProject, TeambitionPagination] +var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.TeambitionConnection, models.TeambitionProject] func Init(br context.BasicRes, p plugin.PluginMeta) { basicRes = br @@ -36,7 +38,7 @@ func Init(br context.BasicRes, p plugin.PluginMeta) { dsHelper = api.NewDataSourceHelper[ models.TeambitionConnection, models.TeambitionProject, - srvhelper.NoScopeConfig, + models.TeambitionScopeConfig, ]( br, p.Name(), @@ -47,4 +49,7 @@ func Init(br context.BasicRes, p plugin.PluginMeta) { nil, nil, ) + raProxy = api.NewDsRemoteApiProxyHelper(dsHelper.ConnApi.ModelApiHelper) + raScopeList = api.NewDsRemoteApiScopeListHelper(raProxy, listTeambitionRemoteScopes) + raScopeSearch = api.NewDsRemoteApiScopeSearchHelper(raProxy, searchTeambitionRemoteProjects) } diff --git a/backend/plugins/teambition/api/remote_api.go b/backend/plugins/teambition/api/remote_api.go new file mode 100644 index 00000000000..97ced735276 --- /dev/null +++ b/backend/plugins/teambition/api/remote_api.go @@ -0,0 +1,170 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "fmt" + "net/url" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models" + "github.com/apache/incubator-devlake/plugins/teambition/models" +) + +type TeambitionPagination struct { + PageToken string `json:"pageToken"` + PageSize int `json:"pageSize"` +} + +func queryTeambitionProjects( + apiClient plugin.ApiClient, + keyword string, + page TeambitionPagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TeambitionProject], + nextPage *TeambitionPagination, + err errors.Error, +) { + if page.PageSize == 0 { + page.PageSize = 50 + } + res, err := apiClient.Get("v3/project/query", url.Values{ + "name": {keyword}, + "pageSize": {fmt.Sprintf("%v", page.PageSize)}, + "pageToken": {page.PageToken}, + }, nil) + if err != nil { + return + } + resBody := struct { + Result []models.TeambitionProject `json:"result"` + NextPageToken string `json:"nextPageToken"` + }{} + err = api.UnmarshalResponse(res, &resBody) + if err != nil { + return + } + for _, project := range resBody.Result { + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.TeambitionProject]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: fmt.Sprintf("%v", project.Id), + ParentId: nil, + Name: project.Name, + FullName: project.Name, + Data: &project, + }) + } + if resBody.NextPageToken != "" { + nextPage = &TeambitionPagination{ + PageToken: resBody.NextPageToken, + PageSize: page.PageSize, + } + } + return +} + +func listTeambitionRemoteScopes( + connection *models.TeambitionConnection, + apiClient plugin.ApiClient, + groupId string, + page TeambitionPagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TeambitionProject], + nextPage *TeambitionPagination, + err errors.Error, +) { + // construct the query and request + return queryTeambitionProjects(apiClient, "", page) +} + +func searchTeambitionRemoteProjects( + apiClient plugin.ApiClient, + params *dsmodels.DsRemoteApiScopeSearchParams, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TeambitionProject], + err errors.Error, +) { + if params.Page == 0 { + params.Page = 1 + } + page := TeambitionPagination{ + PageSize: params.PageSize, + } + children, _, err = queryTeambitionProjects(apiClient, params.Search, page) + return +} + +type Entry = dsmodels.DsRemoteApiScopeListEntry[models.TeambitionProject] +type Node struct { + entry *Entry +} +type Children []*Node + +func (a Children) Len() int { return len(a) } +func (a Children) Less(i, j int) bool { + if a[i].entry.Type != a[j].entry.Type { + return a[i].entry.Type < a[j].entry.Type + } + return a[i].entry.Name < a[j].entry.Name +} +func (a Children) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// RemoteScopes list all available scope for users +// @Summary list all available scope for users +// @Description list all available scope for users +// @Tags plugins/tapd +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param groupId query string false "group ID" +// @Param pageToken query string false "page Token" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.TapdWorkspace] +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes searches scopes on the remote server +// @Summary searches scopes on the remote server +// @Description searches scopes on the remote server +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.SonarqubeProject] "the parentIds are always null" +// @Tags plugins/sonarqube +// @Router /plugins/sonarqube/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeSearch.Get(input) +} + +// @Summary Remote server API proxy +// @Description Forward API requests to the specified remote server +// @Param connectionId path int true "connection ID" +// @Param path path string true "path to a API endpoint" +// @Tags plugins/github +// @Router /plugins/teambition/connections/{connectionId}/proxy/{path} [GET] +func Proxy(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raProxy.Proxy(input) +} diff --git a/backend/plugins/teambition/api/scope_api.go b/backend/plugins/teambition/api/scope_api.go new file mode 100644 index 00000000000..91829726974 --- /dev/null +++ b/backend/plugins/teambition/api/scope_api.go @@ -0,0 +1,107 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/teambition/models" +) + +type PutScopesReqBody api.PutScopesReqBody[models.TeambitionProject] +type ScopeDetail api.ScopeDetail[models.TeambitionProject, models.TeambitionScopeConfig] + +// PutScopes create or update Azure DevOps repo +// @Summary create or update Azure DevOps repo +// @Description Create or update Azure DevOps repo +// @Tags plugins/teambition +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scope body PutScopesReqBody true "json" +// @Success 200 {object} []models.teambitionRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/scopes [PUT] +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// PatchScope patch to Azure DevOps repo +// @Summary patch to Azure DevOps repo +// @Description patch to Azure DevOps repo +// @Tags plugins/teambition +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scopeId path int true "scope ID" +// @Param scope body models.teambitionRepo true "json" +// @Success 200 {object} models.teambitionRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/scopes/{scopeId} [PATCH] +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// GetScopes get Azure DevOps repos +// @Summary get Azure DevOps repos +// @Description get Azure DevOps repos +// @Tags plugins/teambition +// @Param connectionId path int true "connection ID" +// @Param searchTerm query string false "search term for scope name" +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/scopes [GET] +func GetScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScope get one Azure DevOps repo +// @Summary get one Azure DevOps repo +// @Description get one Azure DevOps repo +// @Tags plugins/teambition +// @Param connectionId path int true "connection ID" +// @Param scopeId path int true "scope ID" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/scopes/{scopeId} [GET] +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// DeleteScope delete plugin data associated with the scope and optionally the scope itself +// @Summary delete plugin data associated with the scope and optionally the scope itself +// @Description delete data associated with plugin scope +// @Tags plugins/teambition +// @Param connectionId path int true "connection ID" +// @Param scopeId path int true "scope ID" +// @Param delete_data_only query bool false "Only delete the scope data, not the scope itself" +// @Success 200 {object} models.teambitionRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} srvhelper.DsRefs "References exist to this scope" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/teambition/connections/{connectionId}/scopes/{scopeId} [DELETE] +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} diff --git a/backend/plugins/teambition/api/scope_config_api.go b/backend/plugins/teambition/api/scope_config_api.go new file mode 100644 index 00000000000..6294cb5c7da --- /dev/null +++ b/backend/plugins/teambition/api/scope_config_api.go @@ -0,0 +1,111 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +// PostScopeConfig create scope config for Azure DevOps +// @Summary create scope config for Azure DevOps +// @Description create scope config for Azure DevOps +// @Tags plugins/azuredevops +// @Accept application/json +// @Param connectionId path int true "connectionId" +// @Param scopeConfig body models.AzuredevopsScopeConfig true "scope config" +// @Success 200 {object} models.AzuredevopsScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/connections/{connectionId}/scope-configs [POST] +func PostScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +// PatchScopeConfig update scope config for Azure DevOps +// @Summary update scope config for Azure DevOps +// @Description update scope config for Azure DevOps +// @Tags plugins/azuredevops +// @Accept application/json +// @Param id path int true "id" +// @Param connectionId path int true "connectionId" +// @Param scopeConfig body models.AzuredevopsScopeConfig true "scope config" +// @Success 200 {object} models.AzuredevopsScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/connections/{connectionId}/scope-configs/{id} [PATCH] +func PatchScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +// GetScopeConfig return one scope config +// @Summary return one scope config +// @Description return one scope config +// @Tags plugins/azuredevops +// @Param id path int true "id" +// @Param connectionId path int true "connectionId" +// @Success 200 {object} models.AzuredevopsScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/connections/{connectionId}/scope-configs/{id} [GET] +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +// GetScopeConfigList return all scope configs +// @Summary return all scope configs +// @Description return all scope configs +// @Tags plugins/azuredevops +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Param connectionId path int true "connectionId" +// @Success 200 {object} []models.AzuredevopsScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/connections/{connectionId}/scope-configs [GET] +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +// GetProjectsByScopeConfig return projects details related by scope config +// @Summary return all related projects +// @Description return all related projects +// @Tags plugins/azuredevops +// @Param id path int true "id" +// @Param scopeConfigId path int true "scopeConfigId" +// @Success 200 {object} models.ProjectScopeOutput +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/scope-config/{scopeConfigId}/projects [GET] +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} + +// DeleteScopeConfig delete a scope config +// @Summary delete a scope config +// @Description delete a scope config +// @Tags plugins/azuredevops +// @Param id path int true "id" +// @Param connectionId path int true "connectionId" +// @Success 200 +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/azuredevops/connections/{connectionId}/scope-configs/{id} [DELETE] +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} diff --git a/backend/plugins/teambition/impl/impl.go b/backend/plugins/teambition/impl/impl.go index 2140dfe6d95..a9aa53a7204 100644 --- a/backend/plugins/teambition/impl/impl.go +++ b/backend/plugins/teambition/impl/impl.go @@ -39,8 +39,10 @@ var _ interface { plugin.PluginInit plugin.PluginTask plugin.PluginApi - plugin.CloseablePluginTask + plugin.PluginModel plugin.PluginSource + plugin.DataSourcePluginBlueprintV200 + plugin.CloseablePluginTask } = (*Teambition)(nil) type Teambition struct{} @@ -72,6 +74,7 @@ func (p Teambition) GetTablesInfo() []dal.Tabler { &models.TeambitionProject{}, &models.TeambitionTaskFlowStatus{}, &models.TeambitionTaskScenario{}, + &models.TeambitionScopeConfig{}, } } @@ -180,6 +183,30 @@ func (p Teambition) ApiResources() map[string]map[string]plugin.ApiResourceHandl "connections/:connectionId/test": { "POST": api.TestExistingConnection, }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopes, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "PATCH": api.PatchScopeConfig, + "GET": api.GetScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "connections/:connectionId/scope-configs": { + "POST": api.PostScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, } } diff --git a/backend/plugins/teambition/models/migrationscripts/20250529_add_app_id_back.go b/backend/plugins/teambition/models/migrationscripts/20250529_add_app_id_back.go new file mode 100644 index 00000000000..855bab0a388 --- /dev/null +++ b/backend/plugins/teambition/models/migrationscripts/20250529_add_app_id_back.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addAppIdBack)(nil) + +type teambitionConnection20250529 struct { + AppId string + SecretKey string `gorm:"serializer:encdec"` +} + +func (teambitionConnection20250529) TableName() string { + return "_tool_teambition_connections" +} + +type teambitionScopeConfig20250529 struct { + Entities []string `gorm:"type:json;serializer:json" json:"entities" mapstructure:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` + Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"` + TypeMappings map[string]string `mapstructure:"typeMappings,omitempty" json:"typeMappings" gorm:"serializer:json"` + StatusMappings map[string]string `mapstructure:"statusMappings,omitempty" json:"statusMappings" gorm:"serializer:json"` + BugDueDateField string `mapstructure:"bugDueDateField,omitempty" json:"bugDueDateField" gorm:"column:bug_due_date_field"` + TaskDueDateField string `mapstructure:"taskDueDateField,omitempty" json:"taskDueDateField" gorm:"column:task_due_date_field"` + StoryDueDateField string `mapstructure:"storyDueDateField,omitempty" json:"storyDueDateField" gorm:"column:story_due_date_field"` +} + +func (t teambitionScopeConfig20250529) TableName() string { + return "_tool_teambition_scope_configs" +} + +type addAppIdBack struct{} + +func (*addAppIdBack) Up(basicRes context.BasicRes) errors.Error { + basicRes.GetLogger().Warn(nil, "*********") + err := migrationhelper.AutoMigrateTables(basicRes, &teambitionConnection20250529{}) + basicRes.GetLogger().Warn(err, "err connection") + err = migrationhelper.AutoMigrateTables(basicRes, &teambitionScopeConfig20250529{}) + basicRes.GetLogger().Warn(err, "err scope") + return err +} + +func (*addAppIdBack) Version() uint64 { + return 20250529165745 +} + +func (*addAppIdBack) Name() string { + return "add app id back to teambition_connections" +} diff --git a/backend/plugins/teambition/models/migrationscripts/register.go b/backend/plugins/teambition/models/migrationscripts/register.go index 0f5567d5b00..f9914e7a12c 100644 --- a/backend/plugins/teambition/models/migrationscripts/register.go +++ b/backend/plugins/teambition/models/migrationscripts/register.go @@ -25,5 +25,6 @@ func All() []plugin.MigrationScript { new(addInitTables), new(reCreateTeambitionConnections), new(addScopeConfigId), + new(addAppIdBack), } } diff --git a/backend/plugins/teambition/models/project.go b/backend/plugins/teambition/models/project.go index 0d50e8f0e7a..59531c34f3c 100644 --- a/backend/plugins/teambition/models/project.go +++ b/backend/plugins/teambition/models/project.go @@ -22,10 +22,12 @@ import ( "github.com/apache/incubator-devlake/core/plugin" ) -var _ plugin.ToolLayerScope = (*TeambitionProject)(nil) +func (t TeambitionProject) ConvertApiScope() plugin.ToolLayerScope { + return t +} type TeambitionProject struct { - common.Scope + common.Scope `mapstructure:",squash"` Id string `gorm:"primaryKey;type:varchar(100)" json:"id"` Name string `gorm:"type:varchar(255)" json:"name"` Logo string `gorm:"type:varchar(255)" json:"logo"` @@ -88,3 +90,11 @@ type TeambitionApiParams struct { OrganizationId string ProjectId string } + +type TeambitionProjectsResponse struct { + Status int `json:"status"` + Data []struct { + TeambitionProject `json:"Workspace"` + } `json:"data"` + Info string `json:"info"` +} diff --git a/backend/plugins/teambition/models/scope_config.go b/backend/plugins/teambition/models/scope_config.go new file mode 100644 index 00000000000..d97585cdce1 --- /dev/null +++ b/backend/plugins/teambition/models/scope_config.go @@ -0,0 +1,40 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +type TeambitionScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + TypeMappings map[string]string `mapstructure:"typeMappings,omitempty" json:"typeMappings" gorm:"serializer:json"` + StatusMappings map[string]string `mapstructure:"statusMappings,omitempty" json:"statusMappings" gorm:"serializer:json"` + BugDueDateField string `mapstructure:"bugDueDateField,omitempty" json:"bugDueDateField" gorm:"column:bug_due_date_field"` + TaskDueDateField string `mapstructure:"taskDueDateField,omitempty" json:"taskDueDateField" gorm:"column:task_due_date_field"` + StoryDueDateField string `mapstructure:"storyDueDateField,omitempty" json:"storyDueDateField" gorm:"column:story_due_date_field"` +} + +func (t TeambitionScopeConfig) TableName() string { + return "_tool_teambition_scope_configs" +} + +func (t *TeambitionScopeConfig) SetConnectionId(c *TeambitionScopeConfig, connectionId uint64) { + c.ConnectionId = connectionId + c.ScopeConfig.ConnectionId = connectionId +} diff --git a/backend/plugins/teambition/tasks/project_convertor.go b/backend/plugins/teambition/tasks/project_convertor.go index 53d6fe94cf3..abeddf2456e 100644 --- a/backend/plugins/teambition/tasks/project_convertor.go +++ b/backend/plugins/teambition/tasks/project_convertor.go @@ -19,6 +19,8 @@ package tasks import ( "fmt" + "reflect" + "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/domainlayer" @@ -26,11 +28,10 @@ import ( "github.com/apache/incubator-devlake/core/plugin" helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" "github.com/apache/incubator-devlake/plugins/teambition/models" - "reflect" ) var ConvertProjectsMeta = plugin.SubTaskMeta{ - Name: "convertAccounts", + Name: "convertProjects", EntryPoint: ConvertProjects, EnabledByDefault: true, Description: "convert teambition projects", @@ -56,7 +57,7 @@ func ConvertProjects(taskCtx plugin.SubTaskContext) errors.Error { Input: cursor, Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { userTool := inputRow.(*models.TeambitionProject) - account := &ticket.Board{ + board := &ticket.Board{ DomainEntity: domainlayer.DomainEntity{ Id: getProjectIdGen().Generate(data.Options.ConnectionId, userTool.Id), }, @@ -65,9 +66,12 @@ func ConvertProjects(taskCtx plugin.SubTaskContext) errors.Error { Url: fmt.Sprintf("https://www.teambition.com/project/%s", userTool.Id), CreatedDate: userTool.Created.ToNullableTime(), } - + err := db.CreateOrUpdate(board) + if err != nil { + return nil, err + } return []interface{}{ - account, + board, }, nil }, }) @@ -75,6 +79,5 @@ func ConvertProjects(taskCtx plugin.SubTaskContext) errors.Error { if err != nil { return err } - return converter.Execute() } diff --git a/backend/plugins/teambition/tasks/shared.go b/backend/plugins/teambition/tasks/shared.go index 9831c69b01a..7fbf56efec6 100644 --- a/backend/plugins/teambition/tasks/shared.go +++ b/backend/plugins/teambition/tasks/shared.go @@ -18,8 +18,6 @@ limitations under the License. package tasks import ( - "strings" - "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" @@ -103,25 +101,6 @@ func CreateRawDataSubTaskArgs(taskCtx plugin.SubTaskContext, rawTable string) (* return rawDataSubTaskArgs, &filteredData } -func getStdTypeMappings(data *TeambitionTaskData) map[string]string { - stdTypeMappings := make(map[string]string) - for userType, stdType := range data.Options.TransformationRules.TypeMappings { - stdTypeMappings[userType] = strings.ToUpper(stdType.StandardType) - } - return stdTypeMappings -} - -func getStatusMapping(data *TeambitionTaskData) map[string]string { - statusMapping := make(map[string]string) - mapping := data.Options.TransformationRules.StatusMappings - for std, orig := range mapping { - for _, v := range orig { - statusMapping[v] = std - } - } - return statusMapping -} - func FindAccountById(db dal.Dal, accountId string) (*models.TeambitionAccount, errors.Error) { if accountId == "" { return nil, errors.Default.New("account id must not empty") diff --git a/backend/plugins/teambition/tasks/task_collector.go b/backend/plugins/teambition/tasks/task_collector.go index 14ab165570f..42967499038 100644 --- a/backend/plugins/teambition/tasks/task_collector.go +++ b/backend/plugins/teambition/tasks/task_collector.go @@ -20,11 +20,12 @@ package tasks import ( "encoding/json" "fmt" + "net/http" + "net/url" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" - "net/http" - "net/url" ) const RAW_TASK_TABLE = "teambition_api_tasks" diff --git a/backend/plugins/teambition/tasks/task_converter.go b/backend/plugins/teambition/tasks/task_converter.go index fd212d3791b..aa2071d9cab 100644 --- a/backend/plugins/teambition/tasks/task_converter.go +++ b/backend/plugins/teambition/tasks/task_converter.go @@ -21,6 +21,7 @@ import ( "fmt" "reflect" "strconv" + "strings" "time" "github.com/apache/incubator-devlake/core/dal" @@ -97,36 +98,30 @@ func ConvertTasks(taskCtx plugin.SubTaskContext) errors.Error { issue.OriginalProject = p.Name } - stdStatusMappings := getStatusMapping(data) if taskflowstatus, err := FindTaskFlowStatusById(db, userTool.TfsId); err == nil { issue.OriginalStatus = taskflowstatus.Name - if v, ok := stdStatusMappings[taskflowstatus.Name]; ok { - issue.Status = v - } else { - switch taskflowstatus.Kind { - case "start": - issue.Status = ticket.TODO - case "unset": - issue.Status = ticket.IN_PROGRESS - case "end": - issue.Status = ticket.DONE - } + switch strings.ToUpper(taskflowstatus.Kind) { + case "START": + issue.Status = ticket.TODO + case "UNSET": + issue.Status = ticket.IN_PROGRESS + case "END": + issue.Status = ticket.DONE + } + if issue.Status == "" { + issue.Status = strings.ToUpper(taskflowstatus.Kind) } } - stdTypeMappings := getStdTypeMappings(data) + if scenario, err := FindTaskScenarioById(db, userTool.SfcId); err == nil { issue.OriginalType = scenario.Name - if v, ok := stdTypeMappings[scenario.Name]; ok { - issue.Type = v - } else { - switch scenario.Source { - case "application.bug": - issue.Type = ticket.BUG - case "application.story": - issue.Type = ticket.REQUIREMENT - case "application.risk": - issue.Type = ticket.INCIDENT - } + switch scenario.Source { + case "application.bug": + issue.Type = ticket.BUG + case "application.story": + issue.Type = ticket.REQUIREMENT + case "application.risk": + issue.Type = ticket.INCIDENT } } diff --git a/backend/plugins/teambition/tasks/task_extractor.go b/backend/plugins/teambition/tasks/task_extractor.go index a2c5a721b8c..abe14653c6e 100644 --- a/backend/plugins/teambition/tasks/task_extractor.go +++ b/backend/plugins/teambition/tasks/task_extractor.go @@ -19,6 +19,7 @@ package tasks import ( "encoding/json" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" @@ -54,7 +55,7 @@ func ExtractTasks(taskCtx plugin.SubTaskContext) errors.Error { ConnectionId: data.Options.ConnectionId, TaskId: userRes.Id, TaskTagId: tagId, - ProjectId: userRes.ProjectId, + ProjectId: data.Options.ProjectId, } results = append(results, taskTag) } diff --git a/backend/plugins/teambition/tasks/task_scenario_collector.go b/backend/plugins/teambition/tasks/task_scenario_collector.go index 5f89b13438b..79348c54fe5 100644 --- a/backend/plugins/teambition/tasks/task_scenario_collector.go +++ b/backend/plugins/teambition/tasks/task_scenario_collector.go @@ -20,11 +20,12 @@ package tasks import ( "encoding/json" "fmt" + "net/http" + "net/url" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" - "net/http" - "net/url" ) const RAW_TASK_SCENARIOS_TABLE = "teambition_api_task_scenarios" @@ -32,7 +33,7 @@ const RAW_TASK_SCENARIOS_TABLE = "teambition_api_task_scenarios" var _ plugin.SubTaskEntryPoint = CollectTaskScenarios var CollectTaskScenariosMeta = plugin.SubTaskMeta{ - Name: "collect task flow status", + Name: "collect task scenario", EntryPoint: CollectTaskScenarios, EnabledByDefault: true, Description: "collect teambition task flow scenarios", diff --git a/backend/plugins/teambition/tasks/task_tag_extractor.go b/backend/plugins/teambition/tasks/task_tag_extractor.go index 423d7432a26..b7fdfbdcc38 100644 --- a/backend/plugins/teambition/tasks/task_tag_extractor.go +++ b/backend/plugins/teambition/tasks/task_tag_extractor.go @@ -19,6 +19,7 @@ package tasks import ( "encoding/json" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" @@ -49,7 +50,7 @@ func ExtractTaskTags(taskCtx plugin.SubTaskContext) errors.Error { ConnectionId: data.Options.ConnectionId, Id: userRes.Id, Name: userRes.Name, - ProjectId: userRes.ProjectId, + ProjectId: data.Options.ProjectId, IsArchived: userRes.IsArchived, Updated: userRes.Updated, Created: userRes.Created, diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index ca06b5d1d46..c97d72993eb 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -33,6 +33,7 @@ import { TAPDConfig } from './tapd'; import { WebhookConfig } from './webhook'; import { ZenTaoConfig } from './zentao'; import { OpsgenieConfig } from './opsgenie'; +import { TeambitionConfig } from './teambition'; export const pluginConfigs: IPluginConfig[] = [ AzureConfig, @@ -51,4 +52,5 @@ export const pluginConfigs: IPluginConfig[] = [ ZenTaoConfig, WebhookConfig, OpsgenieConfig, + TeambitionConfig, ].sort((a, b) => a.sort - b.sort); diff --git a/config-ui/src/plugins/register/teambition/assets/icon.svg b/config-ui/src/plugins/register/teambition/assets/icon.svg new file mode 100644 index 00000000000..93d227f72fc --- /dev/null +++ b/config-ui/src/plugins/register/teambition/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/config-ui/src/plugins/register/teambition/config.tsx b/config-ui/src/plugins/register/teambition/config.tsx new file mode 100644 index 00000000000..9653441cd30 --- /dev/null +++ b/config-ui/src/plugins/register/teambition/config.tsx @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { DOC_URL } from '@/release'; + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { ConnectionTenantId, ConnectionTenantType } from './connection-fields'; + +export const TeambitionConfig: IPluginConfig = { + plugin: 'teambition', + name: 'Teambition', + icon: ({ color }) => , + isBeta: true, + sort: 100, + connection: { + docLink: DOC_URL.PLUGIN.TEAMBITION.BASIS, + initialValues: { + endpoint: 'https://open.teambition.com/api/', + tenantType: 'organization', + }, + fields: [ + 'name', + { + key: 'endpoint', + subLabel: 'Your Teambition endpoint URL.', + }, + { + key: 'appId', + label: 'Application App Id', + subLabel: 'Your teambition application App Id.', + }, + { + key: 'secretKey', + label: 'Application Secret Key', + subLabel: 'Your teambition application App Secret.', + }, + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ tenantId: value })} + setError={(error) => setErrors({ tenantId: error })} + /> + ), + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ tenantType: value })} + setError={(error) => setErrors({ tenantType: error })} + /> + ), + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses dynamic rate limit for optimized data collection for Teambition. But you can adjust the collection speed by entering a fixed value. Please note: the rate limit setting applies to all tokens you have entered above.', + learnMore: DOC_URL.PLUGIN.TEAMBITION.RATE_LIMIT, + externalInfo: 'Teambition specifies a maximum QPS of 40.', + defaultValue: 5000, + }, + ], + }, + dataScope: { + searchPlaceholder: 'Please enter at least 3 characters to search', + title: 'Projects', + millerColumn: { + columnCount: 2.5, + firstColumnTitle: 'Subgroups/Projects', + }, + }, + scopeConfig: { + entities: ['TICKET'], + transformation: {}, + }, +}; diff --git a/config-ui/src/plugins/register/teambition/connection-fields/index.ts b/config-ui/src/plugins/register/teambition/connection-fields/index.ts new file mode 100644 index 00000000000..86a33adbd1e --- /dev/null +++ b/config-ui/src/plugins/register/teambition/connection-fields/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './tenant-type'; +export * from './tenant-id'; diff --git a/config-ui/src/plugins/register/teambition/connection-fields/tenant-id.tsx b/config-ui/src/plugins/register/teambition/connection-fields/tenant-id.tsx new file mode 100644 index 00000000000..0455f38833b --- /dev/null +++ b/config-ui/src/plugins/register/teambition/connection-fields/tenant-id.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Input } from 'antd'; +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (value: string) => void; +} + +export const ConnectionTenantId = ({ initialValue, value, setValue, setError }: Props) => { + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + setError(value ? '' : 'TenantId is required'); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + return ( + + + + ); +}; diff --git a/config-ui/src/plugins/register/teambition/connection-fields/tenant-type.tsx b/config-ui/src/plugins/register/teambition/connection-fields/tenant-type.tsx new file mode 100644 index 00000000000..42088d375ae --- /dev/null +++ b/config-ui/src/plugins/register/teambition/connection-fields/tenant-type.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Input } from 'antd'; +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (value: string) => void; +} + +export const ConnectionTenantType = ({ initialValue, value, setValue, setError }: Props) => { + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + setError(value ? '' : 'TenantType is required'); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + return ( + + + + ); +}; diff --git a/config-ui/src/plugins/register/teambition/index.ts b/config-ui/src/plugins/register/teambition/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/teambition/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config';