This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new ac0c40269 fix(tempo): only collect and extract the team of a team
scope (#9319)
ac0c40269 is described below
commit ac0c40269183dd3f6224ccbcefa91de636751900
Author: Shivam Sharma <[email protected]>
AuthorDate: Fri Nov 6 13:09:42 2026 +0530
fix(tempo): only collect and extract the team of a team scope (#9319)
collect-teams fetched every team of the Tempo instance with
connection-wide raw params, and extract_teams turned each of them into
a TempoTeam scope with ScopeConfigId 0. Since the batch save deletes
rows by raw params first, one team scope's pipeline re-created every
team scope of the connection: teams that were never selected became
scopes and the selected ones lost their scope config.
- Scope the team raw data and extracted rows per team ({ConnectionId,
TeamId}, like TempoTeam.ScopeParams and the worklog collector).
- collect-teams: request only the scope's team with teamIds, and drop
the updatedFrom filter, which GET /4/teams doesn't support, along
with the incremental collector state, so each run replaces the raw
rows instead of appending them again.
- extract_teams: skip other teams and keep the existing scope config.
Closes #9318
Generated-by: Claude Code (Claude Opus 5.5)
Signed-off-by: Shivam Sharma
<[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../raw_tables/_raw_tempo_api_teams_team_71.csv | 3 +
.../snapshot_tables/_tool_tempo_teams_scopes.csv | 3 +
.../snapshot_tables/_tool_tempo_teams_team_71.csv | 3 +
backend/plugins/tempo/e2e/worklog_test.go | 29 +++++++++
backend/plugins/tempo/tasks/team_collector.go | 75 +++++++++++-----------
backend/plugins/tempo/tasks/team_collector_test.go | 45 +++++++++++++
backend/plugins/tempo/tasks/team_extractor.go | 64 ++++++++++++------
backend/plugins/tempo/tasks/team_extractor_test.go | 57 ++++++++++++++++
8 files changed, 222 insertions(+), 57 deletions(-)
diff --git
a/backend/plugins/tempo/e2e/raw_tables/_raw_tempo_api_teams_team_71.csv
b/backend/plugins/tempo/e2e/raw_tables/_raw_tempo_api_teams_team_71.csv
new file mode 100644
index 000000000..9531fce29
--- /dev/null
+++ b/backend/plugins/tempo/e2e/raw_tables/_raw_tempo_api_teams_team_71.csv
@@ -0,0 +1,3 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""TeamId"":71}","{""self"":""https://api.tempo.io/4/teams/17"",""id"":17,""name"":""Abstract
BE Team
(renamed)"",""summary"":"""",""key"":""""}","https://api.tempo.io/4/teams?limit=50&offset=0&teamIds=71","{""self"":""https://api.tempo.io/4/teams/17"",""id"":17,""name"":""Abstract
BE Team (renamed)"",""summary"":"""",""key"":""""}","2024-02-15 10:00:00.000"
+2,"{""ConnectionId"":1,""TeamId"":71}","{""self"":""https://api.tempo.io/4/teams/71"",""id"":71,""name"":""SmartShopper
(RTL+WHS+POS)"",""summary"":""Microservice
Applications"",""key"":""""}","https://api.tempo.io/4/teams?limit=50&offset=0&teamIds=71","{""self"":""https://api.tempo.io/4/teams/71"",""id"":71,""name"":""SmartShopper
(RTL+WHS+POS)"",""summary"":""Microservice
Applications"",""key"":""""}","2024-02-15 10:00:00.000"
diff --git
a/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_scopes.csv
b/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_scopes.csv
new file mode 100644
index 000000000..248af6cbf
--- /dev/null
+++ b/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_scopes.csv
@@ -0,0 +1,3 @@
+"connection_id","team_id","scope_config_id","name","summary","key","_raw_data_params","_raw_data_table","_raw_data_id","_raw_data_remark"
+1,17,2,"Abstract BE
Team","","","{""ConnectionId"":1,""TeamId"":0}","_raw_tempo_api_teams",1,
+1,71,3,"SmartShopper (RTL+WHS)","Microservice
Applications","","{""ConnectionId"":1,""TeamId"":0}","_raw_tempo_api_teams",2,
diff --git
a/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_team_71.csv
b/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_team_71.csv
new file mode 100644
index 000000000..6c716bcb1
--- /dev/null
+++ b/backend/plugins/tempo/e2e/snapshot_tables/_tool_tempo_teams_team_71.csv
@@ -0,0 +1,3 @@
+"connection_id","team_id","scope_config_id","name","summary","key","_raw_data_params","_raw_data_table","_raw_data_id","_raw_data_remark"
+1,17,2,"Abstract BE
Team","","","{""ConnectionId"":1,""TeamId"":0}","_raw_tempo_api_teams",1,
+1,71,3,"SmartShopper (RTL+WHS+POS)","Microservice
Applications","","{""ConnectionId"":1,""TeamId"":71}","_raw_tempo_api_teams",2,
diff --git a/backend/plugins/tempo/e2e/worklog_test.go
b/backend/plugins/tempo/e2e/worklog_test.go
index 3d2d5a740..f0a29d3f8 100644
--- a/backend/plugins/tempo/e2e/worklog_test.go
+++ b/backend/plugins/tempo/e2e/worklog_test.go
@@ -102,3 +102,32 @@ func TestTeamDataFlow(t *testing.T) {
),
)
}
+
+func TestTeamScopeExtraction(t *testing.T) {
+ var plugin impl.Tempo
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "tempo", plugin)
+
+ taskData := &tasks.TempoTaskData{
+ Options: &tasks.TempoOptions{
+ ConnectionId: 1,
+ TeamId: 71,
+ },
+ }
+
+ // two team scopes with scope configs; only team 71 is extracted
+
dataflowTester.ImportCsvIntoTabler("./snapshot_tables/_tool_tempo_teams_scopes.csv",
&models.TempoTeam{})
+
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_tempo_api_teams_team_71.csv",
"_raw_tempo_api_teams")
+ dataflowTester.Subtask(tasks.ExtractTeamsMeta, taskData)
+ dataflowTester.VerifyTable(
+ models.TempoTeam{},
+ "./snapshot_tables/_tool_tempo_teams_team_71.csv",
+ e2ehelper.ColumnWithRawData(
+ "connection_id",
+ "team_id",
+ "scope_config_id",
+ "name",
+ "summary",
+ "key",
+ ),
+ )
+}
diff --git a/backend/plugins/tempo/tasks/team_collector.go
b/backend/plugins/tempo/tasks/team_collector.go
index 4c6d0a1d0..92944df14 100644
--- a/backend/plugins/tempo/tasks/team_collector.go
+++ b/backend/plugins/tempo/tasks/team_collector.go
@@ -22,7 +22,6 @@ import (
"net/http"
"net/url"
"strconv"
- "time"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
@@ -42,45 +41,18 @@ var CollectTeamsMeta = plugin.SubTaskMeta{
func CollectTeams(taskCtx plugin.SubTaskContext) errors.Error {
data := taskCtx.GetData().(*TempoTaskData)
- apiCollector, err := api.NewStatefulApiCollector(api.RawDataSubTaskArgs{
- Ctx: taskCtx,
- Params: models.TempoApiParams{
- ConnectionId: data.Options.ConnectionId,
- },
- Table: RAW_TEAM_TABLE,
- })
- if err != nil {
- return err
- }
-
- err = apiCollector.InitCollector(api.ApiCollectorArgs{
- RawDataSubTaskArgs: api.RawDataSubTaskArgs{
- Ctx: taskCtx,
- Params: models.TempoApiParams{
- ConnectionId: data.Options.ConnectionId,
- },
- Table: RAW_TEAM_TABLE,
- },
- ApiClient: data.ApiClient,
- UrlTemplate: "teams",
- PageSize: 50,
+ // Not a stateful (incremental) collector: GET /4/teams has no
+ // "updatedFrom" filter, so an incremental run would append the same
+ // teams to the raw table again instead of refreshing them.
+ apiCollector, err := api.NewApiCollector(api.ApiCollectorArgs{
+ RawDataSubTaskArgs: teamRawDataSubTaskArgs(taskCtx,
data.Options),
+ ApiClient: data.ApiClient,
+ UrlTemplate: "teams",
+ PageSize: 50,
// No GetTotalPages: Tempo v4 pagination metadata
(PageableMetadata)
// has no total, so pages are fetched until one comes back
short.
Query: func(reqData *api.RequestData) (url.Values,
errors.Error) {
- query := url.Values{}
- pager := reqData.Pager
- if pager == nil {
- pager = &api.Pager{Page: 1, Skip: 0, Size: 50}
- }
- query.Set("offset", strconv.Itoa(pager.Skip))
- query.Set("limit", strconv.Itoa(pager.Size))
-
- if apiCollector.IsIncremental() &&
apiCollector.GetSince() != nil {
- since := apiCollector.GetSince()
- query.Set("updatedFrom",
since.Format(time.RFC3339))
- }
-
- return query, nil
+ return buildTeamQuery(data.Options, reqData.Pager), nil
},
ResponseParser: func(res *http.Response) ([]json.RawMessage,
errors.Error) {
var response struct {
@@ -100,3 +72,32 @@ func CollectTeams(taskCtx plugin.SubTaskContext)
errors.Error {
return apiCollector.Execute()
}
+
+// teamRawDataSubTaskArgs scopes the raw team data and the extracted teams per
+// team, like the scope itself (TempoTeam.ScopeParams). The extracted rows are
+// the team scopes: with connection-wide params, extracting one team scope
+// deleted and re-created every team scope of the connection.
+func teamRawDataSubTaskArgs(taskCtx plugin.SubTaskContext, opts *TempoOptions)
api.RawDataSubTaskArgs {
+ return api.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Params: models.TempoApiParams{
+ ConnectionId: opts.ConnectionId,
+ TeamId: opts.TeamId,
+ },
+ Table: RAW_TEAM_TABLE,
+ }
+}
+
+func buildTeamQuery(opts *TempoOptions, pager *api.Pager) url.Values {
+ if pager == nil {
+ pager = &api.Pager{Page: 1, Skip: 0, Size: 50}
+ }
+ query := url.Values{}
+ query.Set("offset", strconv.Itoa(pager.Skip))
+ query.Set("limit", strconv.Itoa(pager.Size))
+ if opts.TeamId != 0 {
+ // only the team of this scope, not every team of the Tempo
instance
+ query.Set("teamIds", strconv.FormatInt(opts.TeamId, 10))
+ }
+ return query
+}
diff --git a/backend/plugins/tempo/tasks/team_collector_test.go
b/backend/plugins/tempo/tasks/team_collector_test.go
new file mode 100644
index 000000000..4a9416650
--- /dev/null
+++ b/backend/plugins/tempo/tasks/team_collector_test.go
@@ -0,0 +1,45 @@
+/*
+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 tasks
+
+import (
+ "testing"
+
+ "github.com/apache/devlake/helpers/pluginhelper/api"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBuildTeamQuery(t *testing.T) {
+ pager := &api.Pager{Page: 2, Skip: 50, Size: 50}
+
+ t.Run("a team scope only fetches its own team", func(t *testing.T) {
+ q := buildTeamQuery(&TempoOptions{ConnectionId: 1, TeamId: 71},
pager)
+ assert.Equal(t, "71", q.Get("teamIds"))
+ assert.Equal(t, "50", q.Get("offset"))
+ assert.Equal(t, "50", q.Get("limit"))
+ // not a GET /4/teams parameter
+ assert.Empty(t, q.Get("updatedFrom"))
+ })
+
+ t.Run("without a team scope, fetches every team", func(t *testing.T) {
+ q := buildTeamQuery(&TempoOptions{ConnectionId: 1}, nil)
+ assert.Empty(t, q.Get("teamIds"))
+ assert.Equal(t, "0", q.Get("offset"))
+ assert.Equal(t, "50", q.Get("limit"))
+ })
+}
diff --git a/backend/plugins/tempo/tasks/team_extractor.go
b/backend/plugins/tempo/tasks/team_extractor.go
index 502100a88..aa5e50042 100644
--- a/backend/plugins/tempo/tasks/team_extractor.go
+++ b/backend/plugins/tempo/tasks/team_extractor.go
@@ -20,6 +20,7 @@ package tasks
import (
"encoding/json"
+ "github.com/apache/devlake/core/dal"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
"github.com/apache/devlake/helpers/pluginhelper/api"
@@ -46,30 +47,29 @@ type TempoTeamResponse struct {
func ExtractTeams(taskCtx plugin.SubTaskContext) errors.Error {
data := taskCtx.GetData().(*TempoTaskData)
+ // The extracted teams are the team scopes themselves, which the
extractor
+ // re-creates: keep the scope config users assigned to them.
+ clauses := []dal.Clause{dal.Where("connection_id = ?",
data.Options.ConnectionId)}
+ if data.Options.TeamId != 0 {
+ clauses = append(clauses, dal.Where("team_id = ?",
data.Options.TeamId))
+ }
+ var existingTeams []models.TempoTeam
+ err := taskCtx.GetDal().All(&existingTeams, clauses...)
+ if err != nil {
+ return err
+ }
+ scopeConfigIds := make(map[int64]uint64, len(existingTeams))
+ for _, team := range existingTeams {
+ scopeConfigIds[team.TeamId] = team.ScopeConfigId
+ }
+
extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{
- RawDataSubTaskArgs: api.RawDataSubTaskArgs{
- Ctx: taskCtx,
- Params: models.TempoApiParams{
- ConnectionId: data.Options.ConnectionId,
- },
- Table: RAW_TEAM_TABLE,
- },
+ RawDataSubTaskArgs: teamRawDataSubTaskArgs(taskCtx,
data.Options),
Extract: func(row *api.RawData) ([]interface{}, errors.Error) {
- var apiTeam TempoTeamResponse
- err := errors.Convert(json.Unmarshal(row.Data,
&apiTeam))
- if err != nil {
+ team, err := extractTeam(row.Data, data.Options,
scopeConfigIds)
+ if err != nil || team == nil {
return nil, err
}
-
- // Transform to tool layer model
- team := &models.TempoTeam{
- TeamId: apiTeam.Id,
- Key: apiTeam.Key,
- Name: apiTeam.Name,
- Summary: apiTeam.Summary,
- }
- team.ConnectionId = data.Options.ConnectionId
-
return []interface{}{team}, nil
},
})
@@ -80,3 +80,27 @@ func ExtractTeams(taskCtx plugin.SubTaskContext)
errors.Error {
return extractor.Execute()
}
+
+// extractTeam returns nil for a team other than the team of this scope, so
+// that collecting one team scope doesn't add every Tempo team as a scope.
+func extractTeam(data []byte, opts *TempoOptions, scopeConfigIds
map[int64]uint64) (*models.TempoTeam, errors.Error) {
+ var apiTeam TempoTeamResponse
+ err := errors.Convert(json.Unmarshal(data, &apiTeam))
+ if err != nil {
+ return nil, err
+ }
+ if opts.TeamId != 0 && apiTeam.Id != opts.TeamId {
+ return nil, nil
+ }
+
+ // Transform to tool layer model
+ team := &models.TempoTeam{
+ TeamId: apiTeam.Id,
+ Key: apiTeam.Key,
+ Name: apiTeam.Name,
+ Summary: apiTeam.Summary,
+ }
+ team.ConnectionId = opts.ConnectionId
+ team.ScopeConfigId = scopeConfigIds[apiTeam.Id]
+ return team, nil
+}
diff --git a/backend/plugins/tempo/tasks/team_extractor_test.go
b/backend/plugins/tempo/tasks/team_extractor_test.go
new file mode 100644
index 000000000..852a16246
--- /dev/null
+++ b/backend/plugins/tempo/tasks/team_extractor_test.go
@@ -0,0 +1,57 @@
+/*
+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 tasks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExtractTeam(t *testing.T) {
+ raw17 :=
[]byte(`{"self":"https://api.tempo.io/4/teams/17","id":17,"name":"Abstract BE
Team","summary":"","key":""}`)
+ raw71 :=
[]byte(`{"self":"https://api.tempo.io/4/teams/71","id":71,"name":"SmartShopper
(RTL+WHS)","summary":"Microservice Applications","key":""}`)
+ scopeConfigIds := map[int64]uint64{71: 3}
+
+ t.Run("keeps only the team of the scope", func(t *testing.T) {
+ team, err := extractTeam(raw17, &TempoOptions{ConnectionId: 1,
TeamId: 71}, scopeConfigIds)
+ assert.Nil(t, err)
+ assert.Nil(t, team)
+ })
+
+ t.Run("keeps the scope config of the team scope", func(t *testing.T) {
+ team, err := extractTeam(raw71, &TempoOptions{ConnectionId: 1,
TeamId: 71}, scopeConfigIds)
+ assert.Nil(t, err)
+ if assert.NotNil(t, team) {
+ assert.Equal(t, uint64(1), team.ConnectionId)
+ assert.Equal(t, int64(71), team.TeamId)
+ assert.Equal(t, "SmartShopper (RTL+WHS)", team.Name)
+ assert.Equal(t, "Microservice Applications",
team.Summary)
+ assert.Equal(t, uint64(3), team.ScopeConfigId)
+ }
+ })
+
+ t.Run("without a team scope, extracts every team", func(t *testing.T) {
+ team, err := extractTeam(raw17, &TempoOptions{ConnectionId: 1},
scopeConfigIds)
+ assert.Nil(t, err)
+ if assert.NotNil(t, team) {
+ assert.Equal(t, int64(17), team.TeamId)
+ assert.Equal(t, uint64(0), team.ScopeConfigId)
+ }
+ })
+}