Skip to content

Commit 00a60b1

Browse files
Merge pull request #11 from arbisoft/feature/taiga-plugin
feat: taiga plugin
2 parents adcb1ae + 28421fd commit 00a60b1

29 files changed

+2208
-0
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
23+
"github.com/apache/incubator-devlake/core/errors"
24+
coreModels "github.com/apache/incubator-devlake/core/models"
25+
"github.com/apache/incubator-devlake/core/models/domainlayer"
26+
"github.com/apache/incubator-devlake/core/models/domainlayer/didgen"
27+
"github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
28+
"github.com/apache/incubator-devlake/core/plugin"
29+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
30+
"github.com/apache/incubator-devlake/helpers/srvhelper"
31+
"github.com/apache/incubator-devlake/plugins/taiga/models"
32+
)
33+
34+
type TaigaTaskOptions struct {
35+
ConnectionId uint64 `json:"connectionId"`
36+
ProjectId uint64 `json:"projectId"`
37+
}
38+
39+
func MakeDataSourcePipelinePlanV200(
40+
subtaskMetas []plugin.SubTaskMeta,
41+
connectionId uint64,
42+
bpScopes []*coreModels.BlueprintScope,
43+
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
44+
// load connection, scope and scopeConfig from the db
45+
connection, err := dsHelper.ConnSrv.FindByPk(connectionId)
46+
if err != nil {
47+
return nil, nil, err
48+
}
49+
scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes)
50+
if err != nil {
51+
return nil, nil, err
52+
}
53+
54+
// needed for the connection to populate its access tokens
55+
_, err = helper.NewApiClientFromConnection(context.TODO(), basicRes, connection)
56+
if err != nil {
57+
return nil, nil, err
58+
}
59+
60+
plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails, connection)
61+
if err != nil {
62+
return nil, nil, err
63+
}
64+
scopes, err := makeScopesV200(scopeDetails, connection)
65+
if err != nil {
66+
return nil, nil, err
67+
}
68+
69+
return plan, scopes, nil
70+
}
71+
72+
func makeDataSourcePipelinePlanV200(
73+
subtaskMetas []plugin.SubTaskMeta,
74+
scopeDetails []*srvhelper.ScopeDetail[models.TaigaProject, models.TaigaScopeConfig],
75+
connection *models.TaigaConnection,
76+
) (coreModels.PipelinePlan, errors.Error) {
77+
plan := make(coreModels.PipelinePlan, len(scopeDetails))
78+
for i, scopeDetail := range scopeDetails {
79+
stage := plan[i]
80+
if stage == nil {
81+
stage = coreModels.PipelineStage{}
82+
}
83+
84+
scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
85+
// construct task options for Taiga
86+
task, err := helper.MakePipelinePlanTask(
87+
"taiga",
88+
subtaskMetas,
89+
scopeConfig.Entities,
90+
TaigaTaskOptions{
91+
ConnectionId: scope.ConnectionId,
92+
ProjectId: uint64(scope.ProjectId),
93+
},
94+
)
95+
if err != nil {
96+
return nil, err
97+
}
98+
99+
stage = append(stage, task)
100+
plan[i] = stage
101+
}
102+
103+
return plan, nil
104+
}
105+
106+
func makeScopesV200(
107+
scopeDetails []*srvhelper.ScopeDetail[models.TaigaProject, models.TaigaScopeConfig],
108+
connection *models.TaigaConnection,
109+
) ([]plugin.Scope, errors.Error) {
110+
scopes := make([]plugin.Scope, 0, len(scopeDetails))
111+
idGen := didgen.NewDomainIdGenerator(&models.TaigaProject{})
112+
113+
for _, scopeDetail := range scopeDetails {
114+
project := scopeDetail.Scope
115+
116+
// add board to scopes
117+
entities := scopeDetail.ScopeConfig.Entities
118+
hasTicket := false
119+
for _, entity := range entities {
120+
if entity == plugin.DOMAIN_TYPE_TICKET {
121+
hasTicket = true
122+
break
123+
}
124+
}
125+
if hasTicket {
126+
domainBoard := &ticket.Board{
127+
DomainEntity: domainlayer.DomainEntity{
128+
Id: idGen.Generate(connection.ID, project.ProjectId),
129+
},
130+
Name: project.Name,
131+
}
132+
scopes = append(scopes, domainBoard)
133+
}
134+
}
135+
136+
return scopes, nil
137+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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+
"fmt"
23+
"net/http"
24+
25+
"github.com/apache/incubator-devlake/core/errors"
26+
"github.com/apache/incubator-devlake/core/plugin"
27+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
28+
"github.com/apache/incubator-devlake/plugins/taiga/models"
29+
"github.com/apache/incubator-devlake/server/api/shared"
30+
)
31+
32+
// TaigaTestConnResponse is the response struct for testing a connection
33+
type TaigaTestConnResponse struct {
34+
shared.ApiBody
35+
Connection *models.TaigaConnection
36+
}
37+
38+
// testConnection tests the Taiga connection
39+
func testConnection(ctx context.Context, connection models.TaigaConnection) (*TaigaTestConnResponse, errors.Error) {
40+
// If username and password are provided, authenticate to get a token
41+
if connection.Username != "" && connection.Password != "" && connection.Token == "" {
42+
// Create a temporary connection without token for authentication
43+
tempConnection := connection
44+
tempConnection.Token = ""
45+
46+
// Create a temporary API client to call the auth endpoint
47+
tempApiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &tempConnection)
48+
if err != nil {
49+
return nil, errors.Default.Wrap(err, "error creating API client")
50+
}
51+
52+
// Prepare auth request body
53+
authBody := map[string]interface{}{
54+
"type": "normal",
55+
"username": connection.Username,
56+
"password": connection.Password,
57+
}
58+
59+
// Authenticate to get token
60+
authResponse := struct {
61+
AuthToken string `json:"auth_token"`
62+
}{}
63+
64+
res, err := tempApiClient.Post("auth", nil, authBody, nil)
65+
if err != nil {
66+
return nil, errors.Default.Wrap(err, "error authenticating with Taiga")
67+
}
68+
69+
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusBadRequest {
70+
return nil, errors.HttpStatus(http.StatusBadRequest).New("authentication failed - please check your username and password")
71+
}
72+
73+
if res.StatusCode != http.StatusOK {
74+
return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("unexpected status code during auth: %d", res.StatusCode))
75+
}
76+
77+
// Parse the auth response
78+
err = api.UnmarshalResponse(res, &authResponse)
79+
if err != nil {
80+
return nil, errors.Default.Wrap(err, "error parsing authentication response")
81+
}
82+
83+
// Set the token for validation
84+
connection.Token = authResponse.AuthToken
85+
}
86+
87+
// validate - but make Token optional if we have username/password
88+
if vld != nil {
89+
if connection.Token == "" && (connection.Username == "" || connection.Password == "") {
90+
return nil, errors.Default.New("either token or username/password must be provided")
91+
}
92+
}
93+
94+
apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &connection)
95+
if err != nil {
96+
return nil, err
97+
}
98+
99+
// test connection by making a request to the user endpoint
100+
res, err := apiClient.Get("users/me", nil, nil)
101+
if err != nil {
102+
return nil, errors.Default.Wrap(err, "error testing connection")
103+
}
104+
105+
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
106+
return nil, errors.HttpStatus(http.StatusBadRequest).New("authentication error when testing connection - please check your credentials")
107+
}
108+
109+
if res.StatusCode != http.StatusOK {
110+
return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("unexpected status code: %d", res.StatusCode))
111+
}
112+
113+
connection = connection.Sanitize()
114+
body := TaigaTestConnResponse{}
115+
body.Success = true
116+
body.Message = "success"
117+
body.Connection = &connection
118+
119+
return &body, nil
120+
}
121+
122+
// TestConnection tests the Taiga connection
123+
// @Summary test taiga connection
124+
// @Description Test Taiga Connection
125+
// @Tags plugins/taiga
126+
// @Param body body models.TaigaConnection true "json body"
127+
// @Success 200 {object} TaigaTestConnResponse "Success"
128+
// @Failure 400 {string} errcode.Error "Bad Request"
129+
// @Failure 500 {string} errcode.Error "Internal Error"
130+
// @Router /plugins/taiga/test [POST]
131+
func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
132+
// decode
133+
var connection models.TaigaConnection
134+
err := api.DecodeMapStruct(input.Body, &connection, false)
135+
if err != nil {
136+
return nil, err
137+
}
138+
// test connection
139+
result, err := testConnection(context.TODO(), connection)
140+
if err != nil {
141+
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
142+
}
143+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
144+
}
145+
146+
// TestExistingConnection tests an existing Taiga connection
147+
// @Summary test existing taiga connection
148+
// @Description Test Existing Taiga Connection
149+
// @Tags plugins/taiga
150+
// @Success 200 {object} TaigaTestConnResponse "Success"
151+
// @Failure 400 {string} errcode.Error "Bad Request"
152+
// @Failure 500 {string} errcode.Error "Internal Error"
153+
// @Router /plugins/taiga/connections/:connectionId/test [POST]
154+
func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
155+
connection, err := dsHelper.ConnApi.GetMergedConnection(input)
156+
if err != nil {
157+
return nil, errors.BadInput.Wrap(err, "find connection from db")
158+
}
159+
// test connection
160+
result, err := testConnection(context.TODO(), *connection)
161+
if err != nil {
162+
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
163+
}
164+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
165+
}
166+
167+
// PostConnections creates a new Taiga connection
168+
// @Summary create taiga connection
169+
// @Description Create Taiga Connection
170+
// @Tags plugins/taiga
171+
// @Success 200 {object} models.TaigaConnection
172+
// @Failure 400
173+
// @Failure 500
174+
// @Router /plugins/taiga/connections [POST]
175+
func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
176+
return dsHelper.ConnApi.Post(input)
177+
}
178+
179+
// ListConnections lists all Taiga connections
180+
// @Summary list taiga connections
181+
// @Description List Taiga Connections
182+
// @Tags plugins/taiga
183+
// @Success 200 {object} []models.TaigaConnection
184+
// @Failure 400
185+
// @Failure 500
186+
// @Router /plugins/taiga/connections [GET]
187+
func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
188+
return dsHelper.ConnApi.GetAll(input)
189+
}
190+
191+
// GetConnection gets a Taiga connection by ID
192+
// @Summary get taiga connection
193+
// @Description Get Taiga Connection
194+
// @Tags plugins/taiga
195+
// @Success 200 {object} models.TaigaConnection
196+
// @Failure 400
197+
// @Failure 500
198+
// @Router /plugins/taiga/connections/:connectionId [GET]
199+
func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
200+
return dsHelper.ConnApi.GetDetail(input)
201+
}
202+
203+
// PatchConnection updates a Taiga connection
204+
// @Summary patch taiga connection
205+
// @Description Patch Taiga Connection
206+
// @Tags plugins/taiga
207+
// @Success 200 {object} models.TaigaConnection
208+
// @Failure 400
209+
// @Failure 500
210+
// @Router /plugins/taiga/connections/:connectionId [PATCH]
211+
func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
212+
return dsHelper.ConnApi.Patch(input)
213+
}
214+
215+
// DeleteConnection deletes a Taiga connection
216+
// @Summary delete taiga connection
217+
// @Description Delete Taiga Connection
218+
// @Tags plugins/taiga
219+
// @Success 200
220+
// @Failure 400
221+
// @Failure 500
222+
// @Router /plugins/taiga/connections/:connectionId [DELETE]
223+
func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
224+
return dsHelper.ConnApi.Delete(input)
225+
}

0 commit comments

Comments
 (0)