diff --git a/backend/core/models/domainlayer/ticket/sprint.go b/backend/core/models/domainlayer/ticket/sprint.go index 29c449aca8f..47d1c2b35df 100644 --- a/backend/core/models/domainlayer/ticket/sprint.go +++ b/backend/core/models/domainlayer/ticket/sprint.go @@ -18,9 +18,10 @@ limitations under the License. package ticket import ( + "time" + "github.com/apache/incubator-devlake/core/models/common" "github.com/apache/incubator-devlake/core/models/domainlayer" - "time" ) var ( @@ -31,13 +32,15 @@ var ( type Sprint struct { domainlayer.DomainEntity - Name string `gorm:"type:varchar(255)"` - Url string `gorm:"type:varchar(255)"` - Status string `gorm:"type:varchar(100)"` - StartedDate *time.Time - EndedDate *time.Time - CompletedDate *time.Time - OriginalBoardID string `gorm:"type:varchar(255)"` + Name string `gorm:"type:varchar(255)"` + Url string `gorm:"type:varchar(255)"` + Status string `gorm:"type:varchar(100)"` + StartedDate *time.Time + EndedDate *time.Time + CompletedDate *time.Time + OriginalBoardID string `gorm:"type:varchar(255)"` + CommittedStoryPoint *float64 + CompletedStoryPoint *float64 } func (Sprint) TableName() string { diff --git a/backend/core/models/migrationscripts/20260722_add_sprint_velocity_fields.go b/backend/core/models/migrationscripts/20260722_add_sprint_velocity_fields.go new file mode 100644 index 00000000000..369b70b035d --- /dev/null +++ b/backend/core/models/migrationscripts/20260722_add_sprint_velocity_fields.go @@ -0,0 +1,49 @@ +/* +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 migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addSprintVelocityFields)(nil) + +type sprint20260722 struct { + CommittedStoryPoint *float64 + CompletedStoryPoint *float64 +} + +func (sprint20260722) TableName() string { + return "sprints" +} + +type addSprintVelocityFields struct{} + +func (script *addSprintVelocityFields) Up(basicRes context.BasicRes) errors.Error { + return basicRes.GetDal().AutoMigrate(new(sprint20260722)) +} + +func (*addSprintVelocityFields) Version() uint64 { + return 20260722100000 +} + +func (*addSprintVelocityFields) Name() string { + return "add committed_story_point/completed_story_point to sprints" +} diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go index e25363a1b40..9dd5e762b4a 100644 --- a/backend/core/models/migrationscripts/register.go +++ b/backend/core/models/migrationscripts/register.go @@ -148,5 +148,6 @@ func All() []plugin.MigrationScript { new(changeIssueComponentToText), new(changeCqIssueCodeBlocksComponentToText), new(addCqProjectMetricsHistory), + new(addSprintVelocityFields), } } diff --git a/backend/plugins/jira/impl/impl.go b/backend/plugins/jira/impl/impl.go index 6fb428a9215..b27ea9daa66 100644 --- a/backend/plugins/jira/impl/impl.go +++ b/backend/plugins/jira/impl/impl.go @@ -85,6 +85,7 @@ func (p Jira) GetTablesInfo() []dal.Tabler { &models.JiraServerInfo{}, &models.JiraSprint{}, &models.JiraSprintIssue{}, + &models.JiraSprintReport{}, &models.JiraStatus{}, &models.JiraWorklog{}, &models.JiraIssueComment{}, @@ -138,6 +139,9 @@ func (p Jira) SubTaskMetas() []plugin.SubTaskMeta { tasks.CollectSprintsMeta, tasks.ExtractSprintsMeta, + tasks.CollectSprintReportMeta, + tasks.ExtractSprintReportMeta, + tasks.CollectEpicsMeta, tasks.ExtractEpicsMeta, @@ -153,6 +157,7 @@ func (p Jira) SubTaskMetas() []plugin.SubTaskMeta { tasks.ConvertSprintsMeta, tasks.ConvertSprintIssuesMeta, + tasks.ConvertSprintReportMeta, tasks.CollectDevelopmentPanelMeta, tasks.ExtractDevelopmentPanelMeta, diff --git a/backend/plugins/jira/models/migrationscripts/20260722_add_sprint_report_table.go b/backend/plugins/jira/models/migrationscripts/20260722_add_sprint_report_table.go new file mode 100644 index 00000000000..c31b709ded6 --- /dev/null +++ b/backend/plugins/jira/models/migrationscripts/20260722_add_sprint_report_table.go @@ -0,0 +1,55 @@ +/* +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 migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +type jiraSprintReport20260722 struct { + ConnectionId uint64 `gorm:"primaryKey"` + BoardId uint64 `gorm:"primaryKey"` + SprintId uint64 `gorm:"primaryKey"` + IssueId uint64 `gorm:"primaryKey"` + + IssueKey string `gorm:"type:varchar(255)"` + Bucket string `gorm:"type:varchar(32);index"` + Done bool + StoryPointsAtSprintStart *float64 + StoryPointsAtSprintEnd *float64 +} + +func (jiraSprintReport20260722) TableName() string { + return "_tool_jira_sprint_reports" +} + +type addSprintReportTable struct{} + +func (script *addSprintReportTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &jiraSprintReport20260722{}) +} + +func (*addSprintReportTable) Version() uint64 { + return 20260722000000 +} + +func (*addSprintReportTable) Name() string { + return "add _tool_jira_sprint_reports table to persist Jira's frozen Sprint Report snapshot" +} diff --git a/backend/plugins/jira/models/migrationscripts/register.go b/backend/plugins/jira/models/migrationscripts/register.go index 379d54f8964..90a3317bd14 100644 --- a/backend/plugins/jira/models/migrationscripts/register.go +++ b/backend/plugins/jira/models/migrationscripts/register.go @@ -58,5 +58,6 @@ func All() []plugin.MigrationScript { new(addSubQueryToBoards), new(changeFixVersionsToText20260707), new(addExtraJQLToScopeConfig), + new(addSprintReportTable), } } diff --git a/backend/plugins/jira/models/sprint_report.go b/backend/plugins/jira/models/sprint_report.go new file mode 100644 index 00000000000..b9280e2e41a --- /dev/null +++ b/backend/plugins/jira/models/sprint_report.go @@ -0,0 +1,64 @@ +/* +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 models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +// Sprint Report bucket values, mirroring the four buckets returned by +// GET rest/greenhopper/1.0/rapid/charts/sprintreport. Jira computes these +// once, at sprint close, so persisting them (rather than reconstructing +// from resolution_date) is what makes committed/completed velocity exact. +const ( + SprintReportBucketCompleted = "completed" + SprintReportBucketNotCompleted = "notCompleted" + SprintReportBucketPunted = "punted" + SprintReportBucketCompletedInOtherSprint = "completedInOtherSprint" +) + +// JiraSprintReport is a frozen, per-(board, sprint, issue) snapshot taken +// from Jira's Sprint Report at sprint close. Unlike JiraSprintIssue (which +// is derived from each issue's live resolution_date and therefore +// mis-attributes carryover issues), this table stores Jira's own +// point-in-time bucketing, so it doesn't drift. +type JiraSprintReport struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + BoardId uint64 `gorm:"primaryKey"` + SprintId uint64 `gorm:"primaryKey"` + IssueId uint64 `gorm:"primaryKey"` + + IssueKey string `gorm:"type:varchar(255)"` + // Bucket is one of the SprintReportBucket* constants above. + Bucket string `gorm:"type:varchar(32);index"` + Done bool + + // StoryPointsAtSprintStart is estimateStatistic.statFieldValue.value in + // Jira's response ("BOS points") — the estimate as it stood when the + // sprint began, i.e. what should be summed for *committed* velocity. + StoryPointsAtSprintStart *float64 + // StoryPointsAtSprintEnd is currentEstimateStatistic.statFieldValue.value + // ("EOS points") — the estimate as of sprint close, i.e. what should be + // summed (for Bucket == completed) for *completed* velocity. + StoryPointsAtSprintEnd *float64 +} + +func (JiraSprintReport) TableName() string { + return "_tool_jira_sprint_reports" +} diff --git a/backend/plugins/jira/tasks/apiv2models/sprint_report.go b/backend/plugins/jira/tasks/apiv2models/sprint_report.go new file mode 100644 index 00000000000..eec40ace9b9 --- /dev/null +++ b/backend/plugins/jira/tasks/apiv2models/sprint_report.go @@ -0,0 +1,73 @@ +/* +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 apiv2models + +import "time" + +// SprintReportInput drives the per-(board, sprint) Sprint Report collector. +// It's what gets iterated over via the DAL cursor, and re-attached to each +// raw row so the extractor knows which board/sprint a response belongs to. +type SprintReportInput struct { + BoardId uint64 `json:"board_id"` + SprintId uint64 `json:"sprint_id"` + // UpdateTime is the sprint's CompleteDate; used as the incremental-sync + // watermark since a sprint report only exists/changes once a sprint closes. + UpdateTime *time.Time `json:"update_time"` +} + +// SprintReportStatFieldValue mirrors Jira's +// {statFieldValue: {value, text}} shape used for per-issue point estimates. +type SprintReportStatFieldValue struct { + Value *float64 `json:"value"` + Text string `json:"text"` +} + +type SprintReportStatistic struct { + StatFieldValue SprintReportStatFieldValue `json:"statFieldValue"` +} + +// SprintReportIssue is one entry inside any of the four bucket lists +// (completedIssues, issuesNotCompletedInCurrentSprint, puntedIssues, +// issuesCompletedInAnotherSprint) in the Sprint Report response. +type SprintReportIssue struct { + Id uint64 `json:"id"` + Key string `json:"key"` + TypeName string `json:"typeName"` + Done bool `json:"done"` + // EstimateStatistic is the issue's estimate as of sprint *start* + // ("BOS points" / committed). + EstimateStatistic SprintReportStatistic `json:"estimateStatistic"` + // CurrentEstimateStatistic is the issue's estimate as of sprint *close* + // ("EOS points" / completed). + CurrentEstimateStatistic SprintReportStatistic `json:"currentEstimateStatistic"` +} + +// SprintReportContents is the "contents" object of the Sprint Report +// response — the frozen snapshot Jira takes at sprint close. +type SprintReportContents struct { + CompletedIssues []SprintReportIssue `json:"completedIssues"` + IssuesNotCompletedInCurrentSprint []SprintReportIssue `json:"issuesNotCompletedInCurrentSprint"` + PuntedIssues []SprintReportIssue `json:"puntedIssues"` + IssuesCompletedInAnotherSprint []SprintReportIssue `json:"issuesCompletedInAnotherSprint"` +} + +// SprintReport is the top-level response of +// GET rest/greenhopper/1.0/rapid/charts/sprintreport?rapidViewId=&sprintId= +type SprintReport struct { + Contents SprintReportContents `json:"contents"` +} diff --git a/backend/plugins/jira/tasks/sprint_report_collector.go b/backend/plugins/jira/tasks/sprint_report_collector.go new file mode 100644 index 00000000000..1f8db1f5cf9 --- /dev/null +++ b/backend/plugins/jira/tasks/sprint_report_collector.go @@ -0,0 +1,116 @@ +/* +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 ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/jira/tasks/apiv2models" +) + +const RAW_SPRINT_REPORT_TABLE = "jira_api_sprint_reports" + +var _ plugin.SubTaskEntryPoint = CollectSprintReport + +var CollectSprintReportMeta = plugin.SubTaskMeta{ + Name: "collectSprintReport", + EntryPoint: CollectSprintReport, + EnabledByDefault: true, + Description: "collect Jira Sprint Report, the frozen committed/completed snapshot Jira takes at sprint close", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +// CollectSprintReport fetches the Greenhopper Sprint Report for every closed +// sprint on this board. It depends on CollectSprints/ExtractSprints having +// already populated _tool_jira_board_sprints and _tool_jira_sprints, since +// that's where the (boardId, sprintId) pairs to query come from. Only closed +// sprints are queried: the report is only a meaningful, frozen snapshot once +// a sprint has actually closed. +func CollectSprintReport(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + apiCollector, err := api.NewStatefulApiCollector(api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: RAW_SPRINT_REPORT_TABLE, + }) + if err != nil { + return err + } + + clauses := []dal.Clause{ + dal.Select("bs.board_id AS board_id, bs.sprint_id AS sprint_id, s.complete_date AS update_time"), + dal.From("_tool_jira_board_sprints bs"), + dal.Join("LEFT JOIN _tool_jira_sprints s ON (bs.connection_id = s.connection_id AND bs.sprint_id = s.sprint_id)"), + dal.Where("bs.connection_id = ? AND bs.board_id = ? AND s.state = ?", data.Options.ConnectionId, data.Options.BoardId, "closed"), + } + if apiCollector.IsIncremental() && apiCollector.GetSince() != nil { + clauses = append(clauses, dal.Where("s.complete_date > ?", apiCollector.GetSince())) + } + cursor, err := db.Cursor(clauses...) + if err != nil { + logger.Error(err, "collect sprint report error") + return err + } + + iterator, err := api.NewDalCursorIterator(db, cursor, reflect.TypeOf(apiv2models.SprintReportInput{})) + if err != nil { + return err + } + + err = apiCollector.InitCollector(api.ApiCollectorArgs{ + ApiClient: data.ApiClient, + Input: iterator, + // e.g. https://xxx.atlassian.net/rest/greenhopper/1.0/rapid/charts/sprintreport?rapidViewId=1&sprintId=2 + UrlTemplate: "greenhopper/1.0/rapid/charts/sprintreport", + Query: func(reqData *api.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*apiv2models.SprintReportInput) + query := url.Values{} + query.Set("rapidViewId", fmt.Sprintf("%d", input.BoardId)) + query.Set("sprintId", fmt.Sprintf("%d", input.SprintId)) + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + blob, e := io.ReadAll(res.Body) + if e != nil { + return nil, errors.Convert(e) + } + return []json.RawMessage{blob}, nil + }, + AfterResponse: ignoreHTTPStatus400, + }) + if err != nil { + return err + } + + return apiCollector.Execute() +} diff --git a/backend/plugins/jira/tasks/sprint_report_convertor.go b/backend/plugins/jira/tasks/sprint_report_convertor.go new file mode 100644 index 00000000000..76046c27319 --- /dev/null +++ b/backend/plugins/jira/tasks/sprint_report_convertor.go @@ -0,0 +1,99 @@ +/* +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 ( + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer/didgen" + "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/jira/models" +) + +var ConvertSprintReportMeta = plugin.SubTaskMeta{ + Name: "convertSprintReport", + EntryPoint: ConvertSprintReport, + EnabledByDefault: true, + Description: "aggregate Jira Sprint Report into committed/completed story points on the domain sprints table", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +type sprintVelocity struct { + SprintId uint64 `json:"sprint_id"` + Committed *float64 `json:"committed"` + Completed *float64 `json:"completed"` +} + +// ConvertSprintReport aggregates _tool_jira_sprint_reports (one row per +// issue per sprint per bucket) into two numbers per sprint: +// - committed: sum of each issue's start-of-sprint estimate, for every +// issue that was part of the sprint when it began (completed, +// notCompleted, and punted issues; punted issues were still committed, +// they were just removed before the sprint closed). +// - completed: sum of each issue's end-of-sprint estimate, for issues in +// the completed bucket only. +// +// It writes these directly onto the existing domain sprints row via +// UpdateColumns rather than going through the usual convert-and-save +// pipeline, specifically so it only touches these two columns and can't +// clobber the Name/Url/Status/dates that ConvertSprints already wrote. +func ConvertSprintReport(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraTaskData) + db := taskCtx.GetDal() + connectionId := data.Options.ConnectionId + sprintIdGen := didgen.NewDomainIdGenerator(&models.JiraSprint{}) + + clauses := []dal.Clause{ + dal.Select(` + sprint_id, + SUM(CASE WHEN bucket IN (?, ?, ?) THEN story_points_at_sprint_start ELSE NULL END) AS committed, + SUM(CASE WHEN bucket = ? THEN story_points_at_sprint_end ELSE NULL END) AS completed + `, + models.SprintReportBucketCompleted, + models.SprintReportBucketNotCompleted, + models.SprintReportBucketPunted, + models.SprintReportBucketCompleted, + ), + dal.From("_tool_jira_sprint_reports"), + dal.Where("connection_id = ? AND board_id = ?", connectionId, data.Options.BoardId), + dal.Groupby("sprint_id"), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + for cursor.Next() { + var row sprintVelocity + if err = db.Fetch(cursor, &row); err != nil { + return err + } + domainSprintId := sprintIdGen.Generate(connectionId, row.SprintId) + updateSet := []dal.DalSet{ + {ColumnName: "committed_story_point", Value: row.Committed}, + {ColumnName: "completed_story_point", Value: row.Completed}, + } + if err = db.UpdateColumns(&ticket.Sprint{}, updateSet, dal.Where("id = ?", domainSprintId)); err != nil { + return err + } + } + + return nil +} diff --git a/backend/plugins/jira/tasks/sprint_report_extractor.go b/backend/plugins/jira/tasks/sprint_report_extractor.go new file mode 100644 index 00000000000..c24c6be36d9 --- /dev/null +++ b/backend/plugins/jira/tasks/sprint_report_extractor.go @@ -0,0 +1,92 @@ +/* +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 ( + "encoding/json" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/jira/models" + "github.com/apache/incubator-devlake/plugins/jira/tasks/apiv2models" +) + +var _ plugin.SubTaskEntryPoint = ExtractSprintReport + +var ExtractSprintReportMeta = plugin.SubTaskMeta{ + Name: "extractSprintReport", + EntryPoint: ExtractSprintReport, + EnabledByDefault: true, + Description: "extract Jira Sprint Report", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ExtractSprintReport(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraTaskData) + connectionId := data.Options.ConnectionId + + extractor, err := api.NewApiExtractor(api.ApiExtractorArgs{ + RawDataSubTaskArgs: api.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: RAW_SPRINT_REPORT_TABLE, + }, + Extract: func(row *api.RawData) ([]interface{}, errors.Error) { + var report apiv2models.SprintReport + if err := errors.Convert(json.Unmarshal(row.Data, &report)); err != nil { + return nil, err + } + var input apiv2models.SprintReportInput + if err := errors.Convert(json.Unmarshal(row.Input, &input)); err != nil { + return nil, err + } + + var result []interface{} + addBucket := func(issues []apiv2models.SprintReportIssue, bucket string) { + for _, issue := range issues { + result = append(result, &models.JiraSprintReport{ + ConnectionId: connectionId, + BoardId: input.BoardId, + SprintId: input.SprintId, + IssueId: issue.Id, + IssueKey: issue.Key, + Bucket: bucket, + Done: issue.Done, + StoryPointsAtSprintStart: issue.EstimateStatistic.StatFieldValue.Value, + StoryPointsAtSprintEnd: issue.CurrentEstimateStatistic.StatFieldValue.Value, + }) + } + } + addBucket(report.Contents.CompletedIssues, models.SprintReportBucketCompleted) + addBucket(report.Contents.IssuesNotCompletedInCurrentSprint, models.SprintReportBucketNotCompleted) + addBucket(report.Contents.PuntedIssues, models.SprintReportBucketPunted) + addBucket(report.Contents.IssuesCompletedInAnotherSprint, models.SprintReportBucketCompletedInOtherSprint) + + return result, nil + }, + }) + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/linear/e2e/snapshot_tables/sprints.csv b/backend/plugins/linear/e2e/snapshot_tables/sprints.csv index a09ab2ffe70..c6324bf0200 100644 --- a/backend/plugins/linear/e2e/snapshot_tables/sprints.csv +++ b/backend/plugins/linear/e2e/snapshot_tables/sprints.csv @@ -1,3 +1,3 @@ -id,name,url,status,started_date,ended_date,completed_date,original_board_id -linear:LinearCycle:1:cycle-1,Cycle 1,,CLOSED,2026-04-20T00:00:00.000+00:00,2026-05-04T00:00:00.000+00:00,2026-05-04T00:00:00.000+00:00,linear:LinearTeam:1:team-1 -linear:LinearCycle:1:cycle-2,Sprint 2,,ACTIVE,2026-05-04T00:00:00.000+00:00,2026-05-18T00:00:00.000+00:00,,linear:LinearTeam:1:team-1 +id,name,url,status,started_date,ended_date,completed_date,original_board_id,committed_story_point,completed_story_point +linear:LinearCycle:1:cycle-1,Cycle 1,,CLOSED,2026-04-20T00:00:00.000+00:00,2026-05-04T00:00:00.000+00:00,2026-05-04T00:00:00.000+00:00,linear:LinearTeam:1:team-1,, +linear:LinearCycle:1:cycle-2,Sprint 2,,ACTIVE,2026-05-04T00:00:00.000+00:00,2026-05-18T00:00:00.000+00:00,,linear:LinearTeam:1:team-1,, diff --git a/backend/plugins/zentao/e2e/snapshot_tables/execution_sprint.csv b/backend/plugins/zentao/e2e/snapshot_tables/execution_sprint.csv index adcc379f389..9568cddeca0 100644 --- a/backend/plugins/zentao/e2e/snapshot_tables/execution_sprint.csv +++ b/backend/plugins/zentao/e2e/snapshot_tables/execution_sprint.csv @@ -1,9 +1,9 @@ -id,name,url,status,started_date,ended_date,completed_date,original_board_id -zentao:ZentaoExecution:1:133,为长期项目创建一个迭代,https://zentaomax.demo.qucheng.cc/execution-view-133.html,,2023-08-28T00:00:00.000+00:00,2023-09-08T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:193,为长期项目创建一个迭代,https://zentaomax.demo.qucheng.cc/execution-view-193.html,ACTIVE,2023-08-28T00:00:00.000+00:00,2023-09-08T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:194,新建一个迭代,完成之后检查sprints表,https://zentaomax.demo.qucheng.cc/execution-view-194.html,CLOSED,2023-08-29T00:00:00.000+00:00,2023-09-05T00:00:00.000+00:00,2023-08-29T08:04:13.000+00:00,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:266,新建迭代然后关闭,https://zentaomax.demo.qucheng.cc/execution-view-266.html,CLOSED,2023-09-07T00:00:00.000+00:00,2023-09-13T00:00:00.000+00:00,2023-09-07T02:15:31.000+00:00,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:267,建一个计划未来开始迭代,https://zentaomax.demo.qucheng.cc/execution-view-267.html,FUTURE,2023-09-09T00:00:00.000+00:00,2023-09-22T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:268,起始-截止都是结束时间,https://zentaomax.demo.qucheng.cc/execution-view-268.html,FUTURE,2023-09-04T00:00:00.000+00:00,2023-09-05T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:269,把迭代挂起,https://zentaomax.demo.qucheng.cc/execution-view-269.html,SUSPENDED,2023-09-07T00:00:00.000+00:00,2023-09-20T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 -zentao:ZentaoExecution:1:270,流程扭转--挂起迭代2,https://zentaomax.demo.qucheng.cc/execution-view-270.html,SUSPENDED,2023-09-07T00:00:00.000+00:00,2023-09-20T00:00:00.000+00:00,,zentao:ZentaoProject:1:192 +id,name,url,status,started_date,ended_date,completed_date,original_board_id,committed_story_point,completed_story_point +zentao:ZentaoExecution:1:133,为长期项目创建一个迭代,https://zentaomax.demo.qucheng.cc/execution-view-133.html,,2023-08-28T00:00:00.000+00:00,2023-09-08T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:193,为长期项目创建一个迭代,https://zentaomax.demo.qucheng.cc/execution-view-193.html,ACTIVE,2023-08-28T00:00:00.000+00:00,2023-09-08T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:194,新建一个迭代,完成之后检查sprints表,https://zentaomax.demo.qucheng.cc/execution-view-194.html,CLOSED,2023-08-29T00:00:00.000+00:00,2023-09-05T00:00:00.000+00:00,2023-08-29T08:04:13.000+00:00,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:266,新建迭代然后关闭,https://zentaomax.demo.qucheng.cc/execution-view-266.html,CLOSED,2023-09-07T00:00:00.000+00:00,2023-09-13T00:00:00.000+00:00,2023-09-07T02:15:31.000+00:00,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:267,建一个计划未来开始迭代,https://zentaomax.demo.qucheng.cc/execution-view-267.html,FUTURE,2023-09-09T00:00:00.000+00:00,2023-09-22T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:268,起始-截止都是结束时间,https://zentaomax.demo.qucheng.cc/execution-view-268.html,FUTURE,2023-09-04T00:00:00.000+00:00,2023-09-05T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:269,把迭代挂起,https://zentaomax.demo.qucheng.cc/execution-view-269.html,SUSPENDED,2023-09-07T00:00:00.000+00:00,2023-09-20T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, +zentao:ZentaoExecution:1:270,流程扭转--挂起迭代2,https://zentaomax.demo.qucheng.cc/execution-view-270.html,SUSPENDED,2023-09-07T00:00:00.000+00:00,2023-09-20T00:00:00.000+00:00,,zentao:ZentaoProject:1:192,, \ No newline at end of file diff --git a/grafana/dashboards/mysql/Jira.json b/grafana/dashboards/mysql/Jira.json index ad663e498aa..62efb8c7a4e 100644 --- a/grafana/dashboards/mysql/Jira.json +++ b/grafana/dashboards/mysql/Jira.json @@ -1145,16 +1145,164 @@ "type": "barchart" }, { + "collapsed": false, "datasource": { "type": "datasource", "uid": "grafana" }, "gridPos": { - "h": 2, + "h": 1, "w": 24, "x": 0, "y": 29 }, + "id": 200, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "3. Sprint Velocity (Committed vs Completed, Closed Sprints)", + "type": "row" + }, + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "description": "Committed = sum of each issue's start-of-sprint estimate for issues that were part of the sprint when it began. Completed = sum of each issue's end-of-sprint estimate for issues Jira's Sprint Report marked done at sprint close. Requires the Jira Sprint Report collector to have run; sprints collected before that will show no bars here.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Story Points", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 201, + "links": [ + { + "targetBlank": true, + "title": "Jira Plugin Docs", + "url": "https://devlake.apache.org/docs/Plugins/jira" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": -45, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "mysql", + "uid": "devlake-mysql-api" + }, + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT\n s.name AS sprint,\n s.committed_story_point AS 'Committed',\n s.completed_story_point AS 'Completed'\nFROM sprints s\n JOIN board_sprints bs ON bs.sprint_id = s.id\nWHERE\n bs.board_id in (${board_id})\n AND s.committed_story_point is not null\nORDER BY s.completed_date asc", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "table": "sprints", + "timeColumn": "completed_date", + "timeColumnType": "timestamp", + "where": [] + } + ], + "title": "Sprint Velocity: Committed vs Completed Story Points [Closed Sprints]", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 38 + }, "id": 130, "options": { "code": { diff --git a/grafana/dashboards/postgresql/Jira.json b/grafana/dashboards/postgresql/Jira.json index 1b200dbb2f3..75931595413 100644 --- a/grafana/dashboards/postgresql/Jira.json +++ b/grafana/dashboards/postgresql/Jira.json @@ -1145,16 +1145,166 @@ "type": "barchart" }, { + "collapsed": false, "datasource": { "type": "datasource", "uid": "grafana" }, "gridPos": { - "h": 2, + "h": 1, "w": 24, "x": 0, "y": 29 }, + "id": 200, + "panels": [], + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "3. Sprint Velocity (Committed vs Completed, Closed Sprints)", + "type": "row" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "description": "Committed = sum of each issue's start-of-sprint estimate for issues that were part of the sprint when it began. Completed = sum of each issue's end-of-sprint estimate for issues Jira's Sprint Report marked done at sprint close. Requires the Jira Sprint Report collector to have run; sprints collected before that will show no bars here.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Story Points", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 201, + "links": [ + { + "targetBlank": true, + "title": "Jira Plugin Docs", + "url": "https://devlake.apache.org/docs/Plugins/jira" + } + ], + "options": { + "barRadius": 0, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "text": { + "valueSize": 12 + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": -45, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "13.0.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "devlake-postgres-api" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT s.name AS sprint, s.committed_story_point AS \"Committed\", s.completed_story_point AS \"Completed\" FROM sprints AS s JOIN board_sprints AS bs ON bs.sprint_id = s.id WHERE ('${board_id:csv}' = '' OR bs.board_id::text = ANY(ARRAY[${board_id:singlequote}]::text[])) AND s.committed_story_point IS NOT NULL ORDER BY s.completed_date ASC NULLS FIRST", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Sprint Velocity: Committed vs Completed Story Points [Closed Sprints]", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 38 + }, "id": 130, "options": { "code": {