Skip to content

Commit e92596f

Browse files
authored
feat(asana): add Asana plugin for project and task collection (#8758)
Add a new Asana plugin that integrates with Asana's REST API to collect projects, sections, tasks, subtasks, stories (comments), tags, and users, mapping them to DevLake's ticket/board domain model. Backend: - Plugin implementation with all required interfaces (PluginMeta, PluginTask, PluginModel, PluginMigration, PluginSource, PluginApi, DataSourcePluginBlueprintV200) - Collectors, extractors, and converters for projects, sections, tasks, subtasks, stories, tags, and users - Remote API scope picker (Workspaces -> Teams/Portfolios -> Projects) - Scope config with issue-type regex transformation rules - Migration scripts for schema evolution - E2E tests with CSV fixtures for project and task data flows Config UI: - Plugin registration with connection form (PAT auth, endpoint, proxy) - Scope config transformation form for issue-type mapping - Dashboard URL integration for onboarding flow Grafana: - Asana dashboard with task metrics and visualizations Made-with: Cursor
1 parent 945fba2 commit e92596f

72 files changed

Lines changed: 6193 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
22+
23+
"github.com/apache/incubator-devlake/plugins/asana/models"
24+
"github.com/apache/incubator-devlake/plugins/asana/tasks"
25+
26+
"github.com/apache/incubator-devlake/core/errors"
27+
coreModels "github.com/apache/incubator-devlake/core/models"
28+
"github.com/apache/incubator-devlake/core/models/domainlayer/didgen"
29+
"github.com/apache/incubator-devlake/core/plugin"
30+
"github.com/apache/incubator-devlake/core/utils"
31+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
32+
"github.com/apache/incubator-devlake/helpers/srvhelper"
33+
)
34+
35+
func MakePipelinePlanV200(
36+
subtaskMetas []plugin.SubTaskMeta,
37+
connectionId uint64,
38+
bpScopes []*coreModels.BlueprintScope,
39+
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
40+
connection, err := dsHelper.ConnSrv.FindByPk(connectionId)
41+
if err != nil {
42+
return nil, nil, err
43+
}
44+
scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes)
45+
if err != nil {
46+
return nil, nil, err
47+
}
48+
plan, err := makePipelinePlanV200(subtaskMetas, scopeDetails, connection)
49+
if err != nil {
50+
return nil, nil, err
51+
}
52+
scopes, err := makeScopesV200(scopeDetails, connection)
53+
return plan, scopes, err
54+
}
55+
56+
func makePipelinePlanV200(
57+
subtaskMetas []plugin.SubTaskMeta,
58+
scopeDetails []*srvhelper.ScopeDetail[models.AsanaProject, models.AsanaScopeConfig],
59+
connection *models.AsanaConnection,
60+
) (coreModels.PipelinePlan, errors.Error) {
61+
plan := make(coreModels.PipelinePlan, len(scopeDetails))
62+
for i, scopeDetail := range scopeDetails {
63+
stage := plan[i]
64+
if stage == nil {
65+
stage = coreModels.PipelineStage{}
66+
}
67+
scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
68+
task, err := helper.MakePipelinePlanTask(
69+
"asana",
70+
subtaskMetas,
71+
scopeConfig.Entities,
72+
tasks.AsanaOptions{
73+
ConnectionId: connection.ID,
74+
ProjectId: scope.Gid,
75+
ScopeConfigId: scopeConfig.ID,
76+
},
77+
)
78+
if err != nil {
79+
return nil, err
80+
}
81+
stage = append(stage, task)
82+
plan[i] = stage
83+
}
84+
return plan, nil
85+
}
86+
87+
func makeScopesV200(
88+
scopeDetails []*srvhelper.ScopeDetail[models.AsanaProject, models.AsanaScopeConfig],
89+
connection *models.AsanaConnection,
90+
) ([]plugin.Scope, errors.Error) {
91+
scopes := make([]plugin.Scope, 0, len(scopeDetails))
92+
idgen := didgen.NewDomainIdGenerator(&models.AsanaProject{})
93+
for _, scopeDetail := range scopeDetails {
94+
scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
95+
id := idgen.Generate(connection.ID, scope.Gid)
96+
if utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_TICKET) {
97+
scopes = append(scopes, ticket.NewBoard(id, scope.Name))
98+
}
99+
}
100+
return scopes, nil
101+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"context"
22+
"net/http"
23+
24+
"github.com/apache/incubator-devlake/core/errors"
25+
"github.com/apache/incubator-devlake/core/plugin"
26+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
"github.com/apache/incubator-devlake/plugins/asana/models"
28+
"github.com/apache/incubator-devlake/server/api/shared"
29+
)
30+
31+
type AsanaTestConnResponse struct {
32+
shared.ApiBody
33+
Connection *models.AsanaConn
34+
}
35+
36+
func testConnection(ctx context.Context, connection models.AsanaConn) (*AsanaTestConnResponse, errors.Error) {
37+
if vld != nil {
38+
if err := vld.Struct(connection); err != nil {
39+
return nil, errors.Default.Wrap(err, "error validating target")
40+
}
41+
}
42+
if connection.GetEndpoint() == "" {
43+
connection.Endpoint = defaultAsanaEndpoint
44+
}
45+
apiClient, err := helper.NewApiClientFromConnection(ctx, basicRes, &connection)
46+
if err != nil {
47+
return nil, err
48+
}
49+
res, err := apiClient.Get("users/me", nil, nil)
50+
if err != nil {
51+
return nil, errors.BadInput.Wrap(err, "verify token failed")
52+
}
53+
if res.StatusCode == http.StatusUnauthorized {
54+
return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error while testing connection")
55+
}
56+
if res.StatusCode != http.StatusOK {
57+
return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code while testing connection")
58+
}
59+
connection = connection.Sanitize()
60+
body := AsanaTestConnResponse{}
61+
body.Success = true
62+
body.Message = "success"
63+
body.Connection = &connection
64+
return &body, nil
65+
}
66+
67+
func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
68+
var connection models.AsanaConn
69+
err := helper.Decode(input.Body, &connection, vld)
70+
if err != nil {
71+
return nil, err
72+
}
73+
if connection.Endpoint == "" {
74+
connection.Endpoint = defaultAsanaEndpoint
75+
}
76+
result, err := testConnection(context.TODO(), connection)
77+
if err != nil {
78+
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
79+
}
80+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
81+
}
82+
83+
func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
84+
connection, err := dsHelper.ConnApi.GetMergedConnection(input)
85+
if err != nil {
86+
return nil, errors.BadInput.Wrap(err, "find connection from db")
87+
}
88+
if err := helper.DecodeMapStruct(input.Body, connection, false); err != nil {
89+
return nil, err
90+
}
91+
if connection.Endpoint == "" {
92+
connection.Endpoint = defaultAsanaEndpoint
93+
}
94+
testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection.AsanaConn)
95+
if testConnectionErr != nil {
96+
return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr)
97+
}
98+
return &plugin.ApiResourceOutput{Body: testConnectionResult, Status: http.StatusOK}, nil
99+
}
100+
101+
func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
102+
if input.Body != nil {
103+
if endpoint, ok := input.Body["endpoint"]; !ok || endpoint == nil || endpoint == "" {
104+
input.Body["endpoint"] = defaultAsanaEndpoint
105+
}
106+
}
107+
return dsHelper.ConnApi.Post(input)
108+
}
109+
110+
func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
111+
return dsHelper.ConnApi.Patch(input)
112+
}
113+
114+
func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
115+
return dsHelper.ConnApi.Delete(input)
116+
}
117+
118+
func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
119+
return dsHelper.ConnApi.GetAll(input)
120+
}
121+
122+
func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
123+
return dsHelper.ConnApi.GetDetail(input)
124+
}

backend/plugins/asana/api/init.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/plugin"
23+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
24+
"github.com/apache/incubator-devlake/plugins/asana/models"
25+
"github.com/go-playground/validator/v10"
26+
)
27+
28+
const defaultAsanaEndpoint = "https://app.asana.com/api/1.0/"
29+
30+
var vld *validator.Validate
31+
var basicRes context.BasicRes
32+
33+
var dsHelper *api.DsHelper[models.AsanaConnection, models.AsanaProject, models.AsanaScopeConfig]
34+
var raProxy *api.DsRemoteApiProxyHelper[models.AsanaConnection]
35+
var raScopeList *api.DsRemoteApiScopeListHelper[models.AsanaConnection, models.AsanaProject, AsanaRemotePagination]
36+
37+
func Init(br context.BasicRes, p plugin.PluginMeta) {
38+
basicRes = br
39+
vld = validator.New()
40+
dsHelper = api.NewDataSourceHelper[
41+
models.AsanaConnection, models.AsanaProject, models.AsanaScopeConfig,
42+
](
43+
br,
44+
p.Name(),
45+
[]string{"name"},
46+
func(c models.AsanaConnection) models.AsanaConnection {
47+
return c.Sanitize()
48+
},
49+
nil,
50+
nil,
51+
)
52+
raProxy = api.NewDsRemoteApiProxyHelper[models.AsanaConnection](dsHelper.ConnApi.ModelApiHelper)
53+
raScopeList = api.NewDsRemoteApiScopeListHelper[models.AsanaConnection, models.AsanaProject, AsanaRemotePagination](raProxy, listAsanaRemoteScopes)
54+
}

0 commit comments

Comments
 (0)