diff --git a/roadmap-planner/backend/cmd/server/main.go b/roadmap-planner/backend/cmd/server/main.go index 6d791463..511e71fa 100644 --- a/roadmap-planner/backend/cmd/server/main.go +++ b/roadmap-planner/backend/cmd/server/main.go @@ -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", diff --git a/roadmap-planner/backend/internal/config/config.go b/roadmap-planner/backend/internal/config/config.go index 11a31034..1dcf3f5b 100644 --- a/roadmap-planner/backend/internal/config/config.go +++ b/roadmap-planner/backend/internal/config/config.go @@ -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. diff --git a/roadmap-planner/backend/internal/contributions/service.go b/roadmap-planner/backend/internal/contributions/service.go index 8dd73eb8..f139c07e 100644 --- a/roadmap-planner/backend/internal/contributions/service.go +++ b/roadmap-planner/backend/internal/contributions/service.go @@ -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 diff --git a/roadmap-planner/backend/internal/contributions/service_extras.go b/roadmap-planner/backend/internal/contributions/service_extras.go index 4d25de9c..962a5625 100644 --- a/roadmap-planner/backend/internal/contributions/service_extras.go +++ b/roadmap-planner/backend/internal/contributions/service_extras.go @@ -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 @@ -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 @@ -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 { 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 diff --git a/roadmap-planner/backend/internal/contributions/status_lanes.go b/roadmap-planner/backend/internal/contributions/status_lanes.go new file mode 100644 index 00000000..1dec026f --- /dev/null +++ b/roadmap-planner/backend/internal/contributions/status_lanes.go @@ -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", + "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", + "已取消", + }, + } +} diff --git a/roadmap-planner/backend/internal/contributions/status_lanes_test.go b/roadmap-planner/backend/internal/contributions/status_lanes_test.go new file mode 100644 index 00000000..72fa7b33 --- /dev/null +++ b/roadmap-planner/backend/internal/contributions/status_lanes_test.go @@ -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}, + {"Acceptance Testing", LaneInProgress, true}, + {"Ready for Delivery", LaneInProgress, true}, + {"Done", LaneDone, true}, + {"Resolved", LaneDone, true}, + {"Cancelled", LaneCancelled, true}, + + // Chinese (DEVOPS Epic workflow). + {"待处理", LaneTodo, true}, + {"调研中", 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) + } +} diff --git a/roadmap-planner/docs/team-analytics/CHANGES.md b/roadmap-planner/docs/team-analytics/CHANGES.md index 5cd8c6a3..da6bb627 100644 --- a/roadmap-planner/docs/team-analytics/CHANGES.md +++ b/roadmap-planner/docs/team-analytics/CHANGES.md @@ -8,32 +8,46 @@ should consult this when upgrading. Newest entries first. +## W4 — Sprint card: 4 lanes (todo / in_progress / done / cancelled) + +**What changed** + +- `SprintStats` JSON gains `todo`, `in_progress`, `cancelled`. `wip` + stays for one release as derived alias `todo + in_progress`. +- New `team_analytics.statuses` config block — four optional lists. + Empty lane inherits the W4 default (37 statuses pulled live from + DEVOPS Jira, English + Chinese mix). +- `contributions/status_lanes.go::StatusClassifier` does the lookup + (case-folded; unknown → `in_progress`, reported `known=false`). +- `sprintCounts` swaps `resolved_at IS NULL` for the classifier. +- Member-profile sprint card renders 6 KPI tiles. + +**Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` +finding B7. + ## W7 — 12-month window + release-cadence "quarter" buckets -**What changed (delivered in this PR)** +**What changed (delivered)** -- `storage.backfill_days` default flips from `180` → `365`. The - first-run JQL pulls a full year of Jira history. +- `storage.backfill_days` default flips from 180 → 365. - `aggregator.RebuildRecent`'s default rebuild window flips from - `187` days → `400` days (cap raised to `730`). + 187 → 400 days (cap 730). - New migration `0008_quarter_assignments.sql` adds a small `(issue_key, quarter_label, source)` table. Empty on install. - New `contributions.QuarterResolver` (period.go). -- `MemberSummary.PeriodTotals []PeriodBucket` populated when the API - gets `?period=release_quarter`. Read-time fold over the existing - weekly rollup via `FoldWeeksByCalendarQuarter`. +- `MemberSummary.PeriodTotals []PeriodBucket` populated on + `?period=release_quarter`. - `parseQuery` default window expands to 364 days when `?period=release_quarter` requests so the chart shows a full year without explicit `from`/`to`. **Deferred to a follow-up PR** -- The Jira sync pass that walks every Milestone, parses +- Jira sync pass that walks every Milestone, parses `^(\d{4}Q[1-4])[::]` (full-width Chinese colon!), follows the - Blocks inward link to the Epic, and writes - `(epic_key, quarter_label)` into `quarter_assignments`. -- The Story / Bug → Epic Link walk that picks up parent-Epic - milestone-quarter. + Blocks inward link to the Epic, writes `(epic_key, quarter_label)` + into `quarter_assignments`. +- Story / Bug → Epic Link walk for parent-Epic milestone-quarter. - Frontend `?period=` tab on the Team Dashboard. **Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.css b/roadmap-planner/frontend/src/components/TeamAnalytics.css index a3013115..959d20c5 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.css +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.css @@ -493,7 +493,7 @@ .ta-sprint-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 14px; margin-top: 6px; } diff --git a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx index 3eeed9a7..13d3842c 100644 --- a/roadmap-planner/frontend/src/components/TeamAnalytics.jsx +++ b/roadmap-planner/frontend/src/components/TeamAnalytics.jsx @@ -1072,8 +1072,10 @@ function MemberView({ memberRow, onBack, onSaved, allRows, orderedPillarNames, o