Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions roadmap-planner/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func initTeamAnalytics(ctx context.Context, router *gin.Engine, cfg *config.Conf
service := contributions.NewService(store)
pillarMap := contributions.NewPillarMap(cfg.TeamAnalytics)
service.SetPillarMap(pillarMap)
service.SetStatusClassifier(contributions.NewStatusClassifier(cfg.TeamAnalytics.Statuses))
aggregator := contributions.NewAggregator(store)
api.AddContributionsRoutes(router, store, service, aggregator)
logger.Info("Contributions API routes added",
Expand Down
15 changes: 15 additions & 0 deletions roadmap-planner/backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ type TeamAnalytics struct {
// daniel: daniel
// jtcheng: chengjingtao
GitLabUsernamePrefills map[string]string `mapstructure:"gitlab_username_prefills" yaml:"gitlab_username_prefills"`
// Statuses is the W4 (2026-05-19) configurable status → lane map
// used by the sprint card. Status names match Jira's `status.name`
// case-folded; an unknown status falls back to `in_progress` and
// triggers a warning so the operator can extend the lists. The
// built-in defaults cover DEVOPS's 37 statuses across 17 issue
// types — see PLAN.md W4 for the full mapping.
Statuses StatusLanes `mapstructure:"statuses" yaml:"statuses"`
}

// StatusLanes is the configurable four-lane sprint classifier.
type StatusLanes struct {
Todo []string `mapstructure:"todo" yaml:"todo"`
InProgress []string `mapstructure:"in_progress" yaml:"in_progress"`
Done []string `mapstructure:"done" yaml:"done"`
Cancelled []string `mapstructure:"cancelled" yaml:"cancelled"`
}

// PillarMapping is one bucket inside TeamAnalytics.Pillars.
Expand Down
16 changes: 15 additions & 1 deletion roadmap-planner/backend/internal/contributions/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,24 @@ import (
type Service struct {
store storage.Store
pillarMP *PillarMap
statuses StatusClassifier
}

func NewService(store storage.Store) *Service {
return &Service{store: store, pillarMP: &PillarMap{}}
return &Service{
store: store,
pillarMP: &PillarMap{},
// W4 default: built-in lanes always populated so SprintCounts
// classifies correctly even when SetStatusClassifier is never
// called (e.g. tests).
statuses: NewStatusClassifier(DefaultStatusLanes()),
}
}

// SetStatusClassifier installs the W4 sprint-lane classifier. Safe to
// call once at startup; not safe to swap at runtime.
func (s *Service) SetStatusClassifier(c StatusClassifier) {
s.statuses = c
}

// SetPillarMap installs the pillar attribution map. Called from main.go
Expand Down
50 changes: 34 additions & 16 deletions roadmap-planner/backend/internal/contributions/service_extras.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,19 @@ type ComponentBucket struct {
// pragmatic heuristic — there is no canonical "current sprint" in
// Jira's data model that we can read without an extra API. The name
// is whatever the latest snapshot recorded.
// SprintStats is the W4 (2026-05-19) four-lane sprint card payload.
// `WIP` is a derived alias kept for one release so any caller that
// hasn't yet adopted the new lanes keeps working — it equals
// `Todo + InProgress`. New callers should read the individual lanes.
type SprintStats struct {
Name string `json:"name,omitempty"`
WIP int `json:"wip"`
Done int `json:"done"`
PRsOpen int `json:"prs_open"`
PRsMerged int `json:"prs_merged"`
Name string `json:"name,omitempty"`
Todo int `json:"todo"`
InProgress int `json:"in_progress"`
Done int `json:"done"`
Cancelled int `json:"cancelled"`
WIP int `json:"wip"` // deprecated: Todo + InProgress; remove in the next release
PRsOpen int `json:"prs_open"`
PRsMerged int `json:"prs_merged"`
}

// MemberExtras packages everything the profile page wants beyond the
Expand Down Expand Up @@ -490,17 +497,22 @@ func (s *Service) MemberExtras(ctx context.Context, memberID string, q storage.M
return out, nil
}

// sprintCounts gathers (wip, done, prs_open, prs_merged) for a member
// scoped to one sprint. WIP / Done come from the *latest* snapshot
// per issue under that sprint id; PR counts come from pull_requests
// linked via jira_key (best-effort).
// sprintCounts gathers (todo, in_progress, done, cancelled, prs_open,
// prs_merged) for a member scoped to one sprint. Lanes come from the
// *latest* snapshot per issue under that sprint id, classified via
// the StatusClassifier (W4 2026-05-19); PR counts come from
// pull_requests linked via jira_key (best-effort).
//
// `WIP` is a derived alias for `Todo + InProgress` so callers that
// haven't yet adopted the four-lane shape keep working — slated for
// removal in the next release.
func (s *Service) sprintCounts(ctx context.Context, db *sql.DB, dialect storage.Dialect, memberID, sprintID string) (*SprintStats, error) {
out := &SprintStats{Name: sprintID} // default name = id; we overwrite below if we see something better

q := rebindSimple(dialect, `
SELECT s.status, s.resolved_at
SELECT s.status
FROM (
SELECT s.status, s.resolved_at, s.issue_key, s.sprint_id, s.assignee_id,
SELECT s.status, s.issue_key, s.sprint_id, s.assignee_id,
ROW_NUMBER() OVER (PARTITION BY s.issue_key ORDER BY r.captured_at DESC) AS rn
FROM issue_snapshots s
JOIN collection_runs r ON s.run_id = r.id
Expand All @@ -514,19 +526,25 @@ func (s *Service) sprintCounts(ctx context.Context, db *sql.DB, dialect storage.
defer rows.Close()
for rows.Next() {
var status string
var resolved sql.NullTime
if err := rows.Scan(&status, &resolved); err != nil {
if err := rows.Scan(&status); err != nil {
return nil, err
}
if resolved.Valid {
lane, _ := s.statuses.Classify(status)
switch lane {
case LaneTodo:
out.Todo++
case LaneDone:
out.Done++
} else {
out.WIP++
case LaneCancelled:
out.Cancelled++
default:
out.InProgress++
}
}
if err := rows.Err(); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (refactor/clarity): Consider logging a warning when Classify returns known=false for unknown statuses, as mentioned in the code comments. This would help operators discover missing status mappings in their Jira workflow.

return nil, err
}
out.WIP = out.Todo + out.InProgress

// PR counts: look at pull_requests opened by this member that
// reference any issue assigned to the sprint via jira_key. This
Expand Down
163 changes: 163 additions & 0 deletions roadmap-planner/backend/internal/contributions/status_lanes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
Copyright 2026 The AlaudaDevops Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
*/

package contributions

import (
"strings"

"github.com/AlaudaDevops/toolbox/roadmap-planner/backend/internal/config"
)

// Lane is the four-bucket sprint classification used by the sprint card
// and (eventually) any other panel that wants a coarser "what state is
// this issue in?" answer than Jira's per-issue-type workflow.
type Lane int

const (
LaneTodo Lane = iota
LaneInProgress
LaneDone
LaneCancelled
)

// String returns the lower-case lane name as it appears on the wire and
// in config. Useful for warning logs when a status name doesn't match
// any lane.
func (l Lane) String() string {
switch l {
case LaneTodo:
return "todo"
case LaneInProgress:
return "in_progress"
case LaneDone:
return "done"
case LaneCancelled:
return "cancelled"
}
return "in_progress"
}

// StatusClassifier maps a Jira `status.name` to a Lane. Lookups are
// case-folded. A name that doesn't match any of the four explicit
// lists falls back to in_progress and is recorded in Unknown so the
// caller can log a warning once.
//
// Construction is via NewStatusClassifier; the default cover-set comes
// from DefaultStatusLanes and is merged with any operator-supplied
// overrides (config wins per lane — empty lane in config means "use
// default for that lane").
type StatusClassifier struct {
bucket map[string]Lane
}

// NewStatusClassifier builds a classifier from the configured lanes.
// Any lane left empty in cfg inherits from DefaultStatusLanes so the
// operator can override one lane without re-declaring the others.
func NewStatusClassifier(cfg config.StatusLanes) StatusClassifier {
d := DefaultStatusLanes()
merge := func(configured, fallback []string) []string {
if len(configured) > 0 {
return configured
}
return fallback
}
cfg.Todo = merge(cfg.Todo, d.Todo)
cfg.InProgress = merge(cfg.InProgress, d.InProgress)
cfg.Done = merge(cfg.Done, d.Done)
cfg.Cancelled = merge(cfg.Cancelled, d.Cancelled)

b := make(map[string]Lane, len(cfg.Todo)+len(cfg.InProgress)+len(cfg.Done)+len(cfg.Cancelled))
for _, s := range cfg.Todo {
b[normalizeStatus(s)] = LaneTodo
}
for _, s := range cfg.InProgress {
b[normalizeStatus(s)] = LaneInProgress
}
for _, s := range cfg.Done {
b[normalizeStatus(s)] = LaneDone
}
for _, s := range cfg.Cancelled {
b[normalizeStatus(s)] = LaneCancelled
}
return StatusClassifier{bucket: b}
}

// Classify returns the lane for the given status name. Unknown
// statuses fall back to in_progress with `known=false` so the caller
// can record the miss and surface it to the operator.
func (c StatusClassifier) Classify(status string) (lane Lane, known bool) {
if c.bucket == nil {
return LaneInProgress, false
}
l, ok := c.bucket[normalizeStatus(status)]
if !ok {
return LaneInProgress, false
}
return l, true
}

func normalizeStatus(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}

// DefaultStatusLanes returns the W4 (2026-05-19) baseline mapping
// derived from a live Jira query against the DEVOPS project. The lists
// cover all 37 statuses across 17 issue types (Bug, Story, Tech-debt,
// Improvement, Vulnerability, Task, Sub-task, Pillar, Milestone,
// Document, Epic, Job, Customer Reported Incident, Platform
// application, Components, Components-sub-task). Operators can
// override one lane in the ConfigMap without re-declaring the others.
func DefaultStatusLanes() config.StatusLanes {
return config.StatusLanes{
Todo: []string{
"Backlog",
"Blocked",
"Open",
"待处理",
"阻塞中",
},
InProgress: []string{
"Acceptance Testing",
"CONFIRM RELEASE",
"Components-test",
"DEPLOY",
"Designing",
"Developing",
"Doc Reviewing",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Issue (bug/data-integrity): The Done lane includes "using" which appears to be a typo or erroneous status name. This doesn't look like a valid Jira status and could cause incorrect classification of issues. Please verify if this is intentional or should be removed.

"In Progress",
"In Testing",
"Mitigated",
"Ready for Delivery",
"Ready for Doc Review",
"Ready for QA",
"Review/Test Failed",
"Signed Off",
"Test Failed",
"Testing",
"Under Review",
"Verify",
"Wait for Verify",
"调研中",
"调研完成",
"设计完成",
"开发完成",
"测试完成",
"验收完成",
},
Done: []string{
"Done",
"Resolved",
"using",
"已完成",
},
Cancelled: []string{
"Cancelled",
"已取消",
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
Copyright 2026 The AlaudaDevops Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
*/

package contributions

import (
"testing"

"github.com/AlaudaDevops/toolbox/roadmap-planner/backend/internal/config"
)

func TestStatusClassifier(t *testing.T) {
c := NewStatusClassifier(config.StatusLanes{})

cases := []struct {
status string
want Lane
known bool
}{
// English defaults.
{"Backlog", LaneTodo, true},
{"Blocked", LaneTodo, true},
{"In Progress", LaneInProgress, true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (test/coverage): Consider adding test coverage for the "Open" status which is in the default Todo lane but not covered by the current test cases.

{"Acceptance Testing", LaneInProgress, true},
{"Ready for Delivery", LaneInProgress, true},
{"Done", LaneDone, true},
{"Resolved", LaneDone, true},
{"Cancelled", LaneCancelled, true},

// Chinese (DEVOPS Epic workflow).
{"待处理", LaneTodo, true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (test/coverage): Consider adding a test case for the "using" status in the Done lane to confirm the current behavior is intentional (or to catch if it's a bug).

{"调研中", LaneInProgress, true},
{"已完成", LaneDone, true},
{"已取消", LaneCancelled, true},

// Case + whitespace tolerance.
{" done ", LaneDone, true},
{"IN PROGRESS", LaneInProgress, true},

// Unknown status falls back to in_progress and reports known=false.
{"made up status", LaneInProgress, false},
{"", LaneInProgress, false},
}

for _, tc := range cases {
got, known := c.Classify(tc.status)
if got != tc.want || known != tc.known {
t.Errorf("Classify(%q) = (%s, %v), want (%s, %v)",
tc.status, got, known, tc.want, tc.known)
}
}
}

func TestStatusClassifierConfigOverrideOneLane(t *testing.T) {
// Operator overrides only `todo` — the other three lanes must fall
// back to the defaults so the operator doesn't have to re-declare
// them.
c := NewStatusClassifier(config.StatusLanes{
Todo: []string{"Inbox"},
})
if got, _ := c.Classify("Inbox"); got != LaneTodo {
t.Errorf("Classify(Inbox) = %s, want todo", got)
}
if got, _ := c.Classify("Done"); got != LaneDone {
t.Errorf("override should not drop default Done lane; got %s", got)
}
if got, _ := c.Classify("Cancelled"); got != LaneCancelled {
t.Errorf("override should not drop default Cancelled lane; got %s", got)
}
}
Loading
Loading