Skip to content

Commit 9e659ab

Browse files
acarmiscFix Bot
andauthored
feat(tempo): add Jira Tempo Timesheets plugin (#8884)
* feat(tempo): add Jira Tempo Timesheets plugin Adds plugin for ingesting worklogs from Jira Tempo (Tempo Timesheets) API v4. Backend: - Plugin entry point (impl/impl.go) - Tempo API client with Bearer token auth (tasks/api_client.go) - Data collectors, extractors, and converters for worklogs and teams - Connection management API endpoints (api/) - Database migrations for _tool_tempo_worklogs, _tool_tempo_teams - E2E tests with CSV fixtures Config-UI: - Connection configuration UI (config.tsx) - Plugin registration in index.ts Dependency: requires Jira plugin for issue ID mapping. Closes #8883 * fix(tempo): resolve CI failures on tempo plugin PR - fix Apache license headers in 5 files (missing/typo content) - move migration script to archived models pattern (forbidden import on plugins/tempo/models) - register tempo in plugins/table_info_test.go - drop unused validator var/import in api/init.go - fix malformed issue_worklogs.csv snapshot (header/body column mismatch) * fix(tempo): align e2e team fixtures with extractor expectations The team extractor queries _raw_tempo_api_teams with the params serialized from TempoApiParams{ConnectionId, TeamId}, expecting the raw fixture rows to store each team as its own record with a single-object data payload. The previous fixture wrapped both teams into a JSON array in the data column and used only ConnectionId in params, causing TestTeamDataFlow to find zero matching rows. Also extend the _tool_tempo_teams snapshot with the _raw_data_* columns that the test verifies via ColumnWithRawData. --------- Co-authored-by: Fix Bot <fix@example.com>
1 parent 8a3af1f commit 9e659ab

32 files changed

Lines changed: 2253 additions & 0 deletions

backend/plugins/table_info_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import (
5757
taiga "github.com/apache/incubator-devlake/plugins/taiga/impl"
5858
tapd "github.com/apache/incubator-devlake/plugins/tapd/impl"
5959
teambition "github.com/apache/incubator-devlake/plugins/teambition/impl"
60+
tempo "github.com/apache/incubator-devlake/plugins/tempo/impl"
6061
testmo "github.com/apache/incubator-devlake/plugins/testmo/impl"
6162
trello "github.com/apache/incubator-devlake/plugins/trello/impl"
6263
webhook "github.com/apache/incubator-devlake/plugins/webhook/impl"
@@ -97,6 +98,7 @@ func Test_GetPluginTablesInfo(t *testing.T) {
9798
checker.FeedIn("taiga/models", taiga.Taiga{}.GetTablesInfo)
9899
checker.FeedIn("tapd/models", tapd.Tapd{}.GetTablesInfo)
99100
checker.FeedIn("teambition/models", teambition.Teambition{}.GetTablesInfo)
101+
checker.FeedIn("tempo/models", tempo.Tempo{}.GetTablesInfo)
100102
checker.FeedIn("testmo/models", testmo.Testmo{}.GetTablesInfo)
101103
checker.FeedIn("trello/models", trello.Trello{}.GetTablesInfo)
102104
checker.FeedIn("webhook/models", webhook.Webhook{}.GetTablesInfo)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
30+
"github.com/apache/incubator-devlake/helpers/srvhelper"
31+
"github.com/apache/incubator-devlake/plugins/tempo/models"
32+
)
33+
34+
func MakeDataSourcePipelinePlanV200(
35+
subtaskMetas []plugin.SubTaskMeta,
36+
connectionId uint64,
37+
bpScopes []*coreModels.BlueprintScope,
38+
) (pp coreModels.PipelinePlan, sc []plugin.Scope, err errors.Error) {
39+
// Load connection, scope and scopeConfig from the db
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+
49+
// Needed for the connection to populate its access tokens
50+
_, err = api.NewApiClientFromConnection(context.TODO(), basicRes, connection)
51+
if err != nil {
52+
return nil, nil, err
53+
}
54+
55+
plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails, connection)
56+
if err != nil {
57+
return nil, nil, err
58+
}
59+
scopes, err := makeScopesV200(scopeDetails, connection)
60+
if err != nil {
61+
return nil, nil, err
62+
}
63+
64+
return plan, scopes, nil
65+
}
66+
67+
func makeDataSourcePipelinePlanV200(
68+
subtaskMetas []plugin.SubTaskMeta,
69+
scopeDetails []*srvhelper.ScopeDetail[models.TempoTeam, models.TempoScopeConfig],
70+
connection *models.TempoConnection,
71+
) (coreModels.PipelinePlan, errors.Error) {
72+
plan := make(coreModels.PipelinePlan, len(scopeDetails))
73+
for i, scopeDetail := range scopeDetails {
74+
stage := plan[i]
75+
if stage == nil {
76+
stage = coreModels.PipelineStage{}
77+
}
78+
79+
scope := scopeDetail.Scope
80+
// Construct task options for Tempo
81+
task, err := api.MakePipelinePlanTask(
82+
"tempo",
83+
subtaskMetas,
84+
nil, // No entities to select for Tempo
85+
map[string]interface{}{
86+
"connectionId": scope.ConnectionId,
87+
"teamId": scope.TeamId,
88+
},
89+
)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
stage = append(stage, task)
95+
plan[i] = stage
96+
}
97+
98+
return plan, nil
99+
}
100+
101+
func makeScopesV200(
102+
scopeDetails []*srvhelper.ScopeDetail[models.TempoTeam, models.TempoScopeConfig],
103+
connection *models.TempoConnection,
104+
) ([]plugin.Scope, errors.Error) {
105+
scopes := make([]plugin.Scope, 0)
106+
for _, scopeDetail := range scopeDetails {
107+
tempoTeam := scopeDetail.Scope
108+
// Add team to scopes
109+
domainTeam := &ticket.Board{
110+
DomainEntity: domainlayer.DomainEntity{
111+
Id: didgen.NewDomainIdGenerator(&models.TempoTeam{}).Generate(tempoTeam.ConnectionId, tempoTeam.TeamId),
112+
},
113+
Name: tempoTeam.Name,
114+
Url: "", // Tempo doesn't provide a direct URL for teams
115+
Type: "team",
116+
}
117+
scopes = append(scopes, domainTeam)
118+
}
119+
return scopes, nil
120+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
"github.com/apache/incubator-devlake/plugins/tempo/models"
28+
"github.com/apache/incubator-devlake/server/api/shared"
29+
)
30+
31+
type TempoTestConnResponse struct {
32+
shared.ApiBody
33+
Connection *models.TempoConnection
34+
}
35+
36+
func testConnection(ctx context.Context, connection models.TempoConnection) (*TempoTestConnResponse, errors.Error) {
37+
// Create API client
38+
apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &connection)
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
// Test connection by fetching teams
44+
res, err := apiClient.Get("teams", nil, nil)
45+
if err != nil {
46+
return nil, errors.Default.Wrap(err, "failed to test connection to Tempo API")
47+
}
48+
49+
if res.StatusCode != http.StatusOK {
50+
return nil, errors.HttpStatus(res.StatusCode).New("failed to connect to Tempo API")
51+
}
52+
53+
// Sanitize and return response
54+
connection = connection.Sanitize()
55+
body := TempoTestConnResponse{}
56+
body.Success = true
57+
body.Message = "success"
58+
body.Connection = &connection
59+
60+
return &body, nil
61+
}
62+
63+
// TestConnection test tempo connection
64+
// @Summary test tempo connection
65+
// @Description Test Tempo Connection
66+
// @Tags plugins/tempo
67+
// @Param body body models.TempoConnection true "json body"
68+
// @Success 200 {object} TempoTestConnResponse "Success"
69+
// @Failure 400 {string} errcode.Error "Bad Request"
70+
// @Failure 500 {string} errcode.Error "Internal Error"
71+
// @Router /plugins/tempo/test [POST]
72+
func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
73+
// Decode
74+
var connection models.TempoConnection
75+
if err := api.Decode(input.Body, &connection, nil); err != nil {
76+
return nil, err
77+
}
78+
// Test connection
79+
result, err := testConnection(context.TODO(), connection)
80+
if err != nil {
81+
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
82+
}
83+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
84+
}
85+
86+
// TestExistingConnection test tempo connection
87+
// @Summary test tempo connection
88+
// @Description Test Tempo Connection
89+
// @Tags plugins/tempo
90+
// @Param connectionId path int true "connection ID"
91+
// @Success 200 {object} TempoTestConnResponse "Success"
92+
// @Failure 400 {string} errcode.Error "Bad Request"
93+
// @Failure 500 {string} errcode.Error "Internal Error"
94+
// @Router /plugins/tempo/connections/{connectionId}/test [POST]
95+
func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
96+
connection, err := dsHelper.ConnApi.GetMergedConnection(input)
97+
if err != nil {
98+
return nil, errors.Convert(err)
99+
}
100+
// Test connection
101+
if result, err := testConnection(context.TODO(), *connection); err != nil {
102+
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
103+
} else {
104+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
105+
}
106+
}
107+
108+
// PostConnections create tempo connection
109+
// @Summary create tempo connection
110+
// @Description Create Tempo connection
111+
// @Tags plugins/tempo
112+
// @Param body body models.TempoConnection true "json body"
113+
// @Success 200 {object} models.TempoConnection
114+
// @Failure 400 {string} errcode.Error "Bad Request"
115+
// @Failure 500 {string} errcode.Error "Internal Error"
116+
// @Router /plugins/tempo/connections [POST]
117+
func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
118+
return dsHelper.ConnApi.Post(input)
119+
}
120+
121+
// PatchConnection patch tempo connection
122+
// @Summary patch tempo connection
123+
// @Description Patch Tempo connection
124+
// @Tags plugins/tempo
125+
// @Param body body models.TempoConnection true "json body"
126+
// @Success 200 {object} models.TempoConnection
127+
// @Failure 400 {string} errcode.Error "Bad Request"
128+
// @Failure 500 {string} errcode.Error "Internal Error"
129+
// @Router /plugins/tempo/connections/{connectionId} [PATCH]
130+
func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
131+
return dsHelper.ConnApi.Patch(input)
132+
}
133+
134+
// DeleteConnection delete a tempo connection
135+
// @Summary delete a tempo connection
136+
// @Description Delete a Tempo connection
137+
// @Tags plugins/tempo
138+
// @Success 200 {object} models.TempoConnection
139+
// @Failure 400 {string} errcode.Error "Bad Request"
140+
// @Failure 409 {object} srvhelper.DsRefs "References exist to this connection"
141+
// @Failure 500 {string} errcode.Error "Internal Error"
142+
// @Router /plugins/tempo/connections/{connectionId} [DELETE]
143+
func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
144+
return dsHelper.ConnApi.Delete(input)
145+
}
146+
147+
// ListConnections get all tempo connections
148+
// @Summary get all tempo connections
149+
// @Description Get all Tempo connections
150+
// @Tags plugins/tempo
151+
// @Success 200 {object} []models.TempoConnection
152+
// @Failure 400 {string} errcode.Error "Bad Request"
153+
// @Failure 500 {string} errcode.Error "Internal Error"
154+
// @Router /plugins/tempo/connections [GET]
155+
func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
156+
return dsHelper.ConnApi.GetAll(input)
157+
}
158+
159+
// GetConnection get tempo connection detail
160+
// @Summary get tempo connection detail
161+
// @Description Get Tempo connection detail
162+
// @Tags plugins/tempo
163+
// @Success 200 {object} models.TempoConnection
164+
// @Failure 400 {string} errcode.Error "Bad Request"
165+
// @Failure 500 {string} errcode.Error "Internal Error"
166+
// @Router /plugins/tempo/connections/{connectionId} [GET]
167+
func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
168+
return dsHelper.ConnApi.GetDetail(input)
169+
}
170+
171+
// GetTeams get teams for a connection
172+
// @Summary get teams
173+
// @Description Get teams for a Tempo connection
174+
// @Tags plugins/tempo
175+
// @Param connectionId path int true "connection ID"
176+
// @Success 200 {object} []models.TempoTeam
177+
// @Failure 400 {object} shared.ApiBody "Bad Request"
178+
// @Failure 500 {object} shared.ApiBody "Internal Error"
179+
// @Router /plugins/tempo/connections/{connectionId}/teams [GET]
180+
func GetTeams(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
181+
connection, err := dsHelper.ConnApi.FindByPk(input)
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
// Create API client
187+
apiClient, err := api.NewApiClientFromConnection(context.TODO(), basicRes, connection)
188+
if err != nil {
189+
return nil, err
190+
}
191+
192+
// Get teams from Tempo API
193+
res, err := apiClient.Get("teams", nil, nil)
194+
if err != nil {
195+
return nil, errors.Default.Wrap(err, "failed to get teams from Tempo API")
196+
}
197+
198+
var teams []models.TempoTeamResponse
199+
err = api.UnmarshalResponse(res, &teams)
200+
if err != nil {
201+
return nil, errors.Default.Wrap(err, "failed to unmarshal teams response")
202+
}
203+
204+
// Convert to tool layer models
205+
result := make([]models.TempoTeam, 0, len(teams))
206+
for _, t := range teams {
207+
result = append(result, *t.ConvertToToolLayer(connection.ID))
208+
}
209+
210+
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
211+
}

0 commit comments

Comments
 (0)