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 21ea61c30 feat(jira): persist Sprint Report snapshot for accurate 
committed/completed velocity (#8967) (#9010)
21ea61c30 is described below

commit 21ea61c300e029cb1684a3f70863ce6ebabaa555
Author: Bramhanand Lingala <[email protected]>
AuthorDate: Mon Jul 27 02:29:53 2026 -0400

    feat(jira): persist Sprint Report snapshot for accurate committed/completed 
velocity (#8967) (#9010)
---
 backend/core/models/domainlayer/ticket/sprint.go   |  19 +--
 .../20260722_add_sprint_velocity_fields.go         |  49 +++++++
 backend/core/models/migrationscripts/register.go   |   1 +
 backend/plugins/jira/impl/impl.go                  |   5 +
 .../20260722_add_sprint_report_table.go            |  55 ++++++++
 .../jira/models/migrationscripts/register.go       |   1 +
 backend/plugins/jira/models/sprint_report.go       |  64 +++++++++
 .../jira/tasks/apiv2models/sprint_report.go        |  73 ++++++++++
 .../plugins/jira/tasks/sprint_report_collector.go  | 116 ++++++++++++++++
 .../plugins/jira/tasks/sprint_report_convertor.go  |  99 ++++++++++++++
 .../plugins/jira/tasks/sprint_report_extractor.go  |  92 +++++++++++++
 .../plugins/linear/e2e/snapshot_tables/sprints.csv |   6 +-
 .../e2e/snapshot_tables/execution_sprint.csv       |  18 +--
 grafana/dashboards/mysql/Jira.json                 | 150 +++++++++++++++++++-
 grafana/dashboards/postgresql/Jira.json            | 152 ++++++++++++++++++++-
 15 files changed, 878 insertions(+), 22 deletions(-)

diff --git a/backend/core/models/domainlayer/ticket/sprint.go 
b/backend/core/models/domainlayer/ticket/sprint.go
index 29c449aca..47d1c2b35 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 000000000..369b70b03
--- /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 e25363a1b..9dd5e762b 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 6fb428a92..b27ea9daa 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 000000000..c31b709de
--- /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 379d54f89..90a3317bd 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 000000000..b9280e2e4
--- /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 000000000..eec40ace9
--- /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 000000000..1f8db1f5c
--- /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 000000000..76046c273
--- /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 000000000..c24c6be36
--- /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 a09ab2ffe..c6324bf02 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 adcc379f3..9568cddec 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 ad663e498..62efb8c7a 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 1b200dbb2..759315954 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": {


Reply via email to