Skip to content

Commit 14aee3c

Browse files
committed
feat(asana): add Asana plugin with deployment workflows
1 parent 93ce698 commit 14aee3c

66 files changed

Lines changed: 6544 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,93 @@ Located in `backend/python/plugins/`. Use Poetry for dependencies. See [backend/
130130
- Migration scripts must be added to `All()` in `register.go`
131131
- API changes require running `make swag` to update Swagger docs
132132
- Python plugins require `libgit2` for gitextractor functionality
133+
134+
## Asana Plugin Implementation (2025-02-03)
135+
136+
### Overview
137+
Implemented a complete Asana plugin (`backend/plugins/asana/`) to collect projects (boards), sections, and tasks from Asana's REST API and map them to DevLake's ticket/board domain model.
138+
139+
### Architecture Decisions
140+
- **Scope Model**: Asana **Project** = DevLake **Board** (scope)
141+
- **Authentication**: Personal Access Token (PAT) via Bearer token (`Authorization: Bearer <token>`)
142+
- **API Base URL**: `https://app.asana.com/api/1.0/` (default endpoint)
143+
- **Pagination**: Asana uses offset-based pagination via `next_page.offset` in response; implemented sequential fetching with `GetNextPageCustomData`
144+
145+
### Implementation Details
146+
147+
#### Models (`backend/plugins/asana/models/`)
148+
- **Connection** (`connection.go`): `AsanaConn` (Token, RestConnection), `AsanaConnection` (BaseConnection + AsanaConn)
149+
- **Scope** (`project.go`): `AsanaProject` implements `ToolLayerScope`; primary key: `(ConnectionId, Gid)`
150+
- **Scope Config** (`scope_config.go`): `AsanaScopeConfig` embeds `common.ScopeConfig` with `Entities: ["TICKET"]`
151+
- **Tool Layer**:
152+
- `task.go`: `AsanaTask` (Gid, Name, Notes, Completed, DueOn, ProjectGid, SectionGid, AssigneeGid, CreatorGid, etc.)
153+
- `section.go`: `AsanaSection` (Gid, Name, ProjectGid)
154+
- `user.go`: `AsanaUser` (Gid, Name, Email) - optional, for assignee/creator enrichment
155+
- **Migration**: `20250203_add_init_tables.go` creates all tables via `migrationhelper.AutoMigrateTables`
156+
157+
#### API Layer (`backend/plugins/asana/api/`)
158+
- **Connection API** (`connection_api.go`): Test connection via `GET users/me`, CRUD operations, default endpoint handling
159+
- **Scope API** (`scope_api.go`): PutScopes, GetScopeList, GetScope, PatchScope, DeleteScope (uses `:scopeId` path param, not `:projectId`)
160+
- **Scope Config API** (`scope_config_api.go`): Full CRUD for scope configs
161+
- **Blueprint V200** (`blueprint_v200.go`): Maps Asana projects to `ticket.Board` domain entities when scope config includes `DOMAIN_TYPE_TICKET`
162+
- **Remote API** (`remote_api.go`): Proxy endpoint for direct API access
163+
- **Init** (`init.go`): Sets up `DsHelper[AsanaConnection, AsanaProject, AsanaScopeConfig]` with default endpoint
164+
165+
#### Tasks (`backend/plugins/asana/tasks/`)
166+
- **Project**: `project_collector.go` (GET `/projects/{gid}`), `project_extractor.go` (extracts to `_tool_asana_projects`)
167+
- **Section**: `section_collector.go` (GET `/projects/{gid}/sections`), `section_extractor.go` (extracts to `_tool_asana_sections`)
168+
- **Task**:
169+
- `task_collector.go`: Collects tasks with offset pagination (limit=100, offset from `next_page.offset`)
170+
- `task_extractor.go`: Extracts task data including memberships (project/section), assignee, creator, parent
171+
- `task_convertor.go`: Converts `AsanaTask` → `ticket.Issue` + `ticket.BoardIssue` using `didgen` for domain IDs
172+
- **API Client** (`api_client.go`): Creates `ApiAsyncClient` with Bearer auth, sets default endpoint if missing
173+
- **Task Data** (`task_data.go`): `AsanaOptions` (ConnectionId, ProjectId, ScopeConfigId), `AsanaTaskData`, `CreateRawDataSubTaskArgs` helper
174+
175+
#### Plugin Entry (`backend/plugins/asana/`)
176+
- **Main** (`asana.go`): `PluginEntry impl.Asana` for plugin loading
177+
- **Impl** (`impl/impl.go`): Implements all required interfaces:
178+
- `PluginMeta`: Name="asana", Description, RootPkgPath
179+
- `PluginTask`: SubTaskMetas (CollectProject, ExtractProject, CollectSection, ExtractSection, CollectTask, ExtractTask, ConvertTask)
180+
- `PluginModel`: GetTablesInfo() returns all 6 models
181+
- `PluginMigration`: MigrationScripts() from migrationscripts.All()
182+
- `PluginApi`: ApiResources() with connections, scopes, scope-configs, test, proxy routes
183+
- `PluginSource`: Connection(), Scope(), ScopeConfig()
184+
- `DataSourcePluginBlueprintV200`: MakeDataSourcePipelinePlanV200()
185+
- `CloseablePluginTask`: Close() releases ApiClient
186+
187+
#### Config UI (`config-ui/src/plugins/register/asana/`)
188+
- **Config** (`config.tsx`): `AsanaConfig` with:
189+
- Connection fields: name, endpoint (default: `https://app.asana.com/api/1.0/`), token, proxy, rateLimitPerHour (default: 150)
190+
- Data scope title: "Projects"
191+
- Scope config entities: `['TICKET']`
192+
- **Icon** (`assets/icon.svg`): Placeholder SVG icon
193+
- **Registration** (`index.ts`): Exports `AsanaConfig`
194+
- **Plugin Registry** (`config-ui/src/plugins/register/index.ts`): Added `AsanaConfig` to `pluginConfigs` array
195+
196+
#### Testing
197+
- **Table Info Test** (`backend/plugins/table_info_test.go`): Added `asana` import and `checker.FeedIn("asana/models", asana.Asana{}.GetTablesInfo)`
198+
- **E2E Test** (`backend/plugins/asana/e2e/task_test.go`):
199+
- Imports raw task CSV (`e2e/raw_tables/_raw_asana_tasks.csv`)
200+
- Runs `ExtractTaskMeta` subtask
201+
- Verifies `_tool_asana_tasks` against snapshot (`e2e/snapshot_tables/_tool_asana_tasks.csv`)
202+
203+
### Key Implementation Notes
204+
1. **Scope ID**: Uses `:scopeId` path param (not `:projectId`) to match generic scope helper expectations; scope ID is Asana project GID
205+
2. **Response Parsing**: Asana API wraps responses in `{"data": {...}}` or `{"data": [...], "next_page": {...}}`; collectors unwrap `data` field
206+
3. **Date Parsing**: `due_on` is date-only string (`YYYY-MM-DD`); `parseAsanaDate()` helper converts to `*time.Time`
207+
4. **Task Memberships**: Tasks can belong to multiple projects/sections; extractor uses first section from memberships array
208+
5. **Domain Conversion**: Task status maps: `completed=true` → `ticket.DONE`, `completed=false` → `ticket.TODO`
209+
6. **Default Endpoint**: Set in `api/init.go` constant and applied in `PostConnections` if missing from request
210+
211+
### Files Created
212+
- **Backend**: 25+ Go files across `models/`, `api/`, `tasks/`, `impl/`, `e2e/`
213+
- **Config UI**: 3 TypeScript files (`config.tsx`, `index.ts`, `assets/icon.svg`)
214+
- **Test**: 1 CSV fixture pair (raw + snapshot), 1 E2E test file
215+
- **CI**: Updated `table_info_test.go`
216+
217+
### Next Steps (Optional Enhancements)
218+
- Add user collection/enrichment for assignee/creator names
219+
- Support OAuth 2.0 authentication (currently PAT only)
220+
- Add task comments/subtasks collection
221+
- Implement incremental collection with time-based bookmarking
222+
- Add transformation rules for custom field mappings
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)