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
31 changes: 26 additions & 5 deletions roadmap-planner/backend/internal/api/handlers/contributions.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,15 @@ func (h *ContributionsHandler) ListMembers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"members": members})
}

// TeamOverview — GET /api/contributions/team?from=&to=&pillar=&component=&include_inactive=
// TeamOverview — GET /api/contributions/team?from=&to=&pillar=&component=&include_inactive=&period=
//
// Inactive members are dropped from the rollup table by default for the
// same reason as ListMembers.
//
// W7 2026-05-19: `?period=release_quarter` folds the per-week buckets
// into `<YYYY>Q<n>` labels via the calendar-quarter resolver (until
// the Milestone-prefix Jira sync pass populates the
// `quarter_assignments` table with authoritative labels).
func (h *ContributionsHandler) TeamOverview(c *gin.Context) {
q, err := h.parseQuery(c)
if err != nil {
Expand All @@ -68,6 +73,11 @@ func (h *ContributionsHandler) TeamOverview(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if c.Query("period") == contributions.PeriodReleaseQuarter {
for i := range out {
out[i].PeriodTotals = contributions.FoldWeeksByCalendarQuarter(out[i].WeekTotals)

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 (consistency/period-constant): Consider using contributions.PeriodReleaseQuarter constant instead of string literal "release_quarter" for maintainability.

}
}
if !truthy(c.Query("include_inactive")) {
members, mErr := h.store.ListMembers(c.Request.Context())
if mErr == nil {
Expand Down Expand Up @@ -110,6 +120,9 @@ func (h *ContributionsHandler) MemberDetail(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if c.Query("period") == contributions.PeriodReleaseQuarter && summary != nil {
summary.PeriodTotals = contributions.FoldWeeksByCalendarQuarter(summary.WeekTotals)
}
// Enrich with the directory entry so the profile page has the
// identity fields (display name, email, github_login, …) without a
// second round-trip.
Expand All @@ -125,6 +138,7 @@ func (h *ContributionsHandler) MemberDetail(c *gin.Context) {
resp := gin.H{
"member_id": summary.MemberID,
"week_totals": summary.WeekTotals,
"period_totals": summary.PeriodTotals,
"jira_issues_done": summary.JiraIssuesDone,
"jira_points_done": summary.JiraPointsDone,
"prs_merged": summary.PRsMerged,
Expand Down Expand Up @@ -316,14 +330,21 @@ func (h *ContributionsHandler) CollectorStatus(c *gin.Context) {
c.JSON(http.StatusOK, out)
}

// parseQuery normalises ?from=&to=&pillar=&component=&member=.
// parseQuery normalises ?from=&to=&pillar=&component=&member=&period=.
//
// Default window is the last 12 weeks (84 days), which matches the
// prototype and is the most common dashboard view.
// Default window: the last 12 weeks (84 days) when the caller stays on
// week-period; the last ~12 months (364 days, four full quarters)
// when the caller asks for `?period=release_quarter`. W7 (2026-05-19):
// dashboards that flip to the quarter view get a full year of buckets
// without having to pass an explicit `from`.
func (h *ContributionsHandler) parseQuery(c *gin.Context) (storage.MemberWeekQuery, error) {
now := time.Now().UTC()
defaultBack := 84
if c.Query("period") == contributions.PeriodReleaseQuarter {
defaultBack = 364
}
q := storage.MemberWeekQuery{
From: contributions.MondayOf(now.AddDate(0, 0, -84)),
From: contributions.MondayOf(now.AddDate(0, 0, -defaultBack)),
To: contributions.MondayOf(now.AddDate(0, 0, 7)),
}
if v := c.Query("from"); v != "" {
Expand Down
2 changes: 1 addition & 1 deletion roadmap-planner/backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ func Load() (*Config, error) {
viper.SetDefault("storage.type", "sqlite")
viper.SetDefault("storage.path", "./data/roadmap.db")
viper.SetDefault("storage.dsn", "")
viper.SetDefault("storage.backfill_days", 180)
viper.SetDefault("storage.backfill_days", 365)

// GitLab defaults
viper.SetDefault("gitlab.enabled", false)
Expand Down
12 changes: 7 additions & 5 deletions roadmap-planner/backend/internal/contributions/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,16 @@ func (a *Aggregator) Rebuild(ctx context.Context, from, to time.Time) error {
// large enough to cover both the backfill default and any out-of-band
// updates we may have absorbed (e.g., a back-dated `resolved` change).
//
// Default window is the last (storageBackfillDays + 7) days, capped at
// 365. Pass days=0 to use the default.
// Default window is the last 400 days (W7 2026-05-19, was 187), capped
// at 730. Pass days=0 to use the default. The bump matches the
// 365-day Storage.BackfillDays so the first rebuild after a fresh
// deploy fully populates the year of rollup rows.

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 (consistency/naming): The comment says "was 187" but could include the W7 date (2026-05-19) for future maintainers.

func (a *Aggregator) RebuildRecent(ctx context.Context, days int) error {
if days <= 0 {
days = 187
days = 400
}
if days > 365 {
days = 365
if days > 730 {
days = 730
}
now := time.Now().UTC()
return a.Rebuild(ctx, MondayOf(now.AddDate(0, 0, -days)), MondayOf(now.AddDate(0, 0, 7)))
Expand Down
111 changes: 111 additions & 0 deletions roadmap-planner/backend/internal/contributions/period.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
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 (
"context"
"database/sql"
"fmt"
"time"

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

// Period names accepted by `?period=` on the contributions endpoints.
const (
PeriodWeek = "week"
PeriodReleaseQuarter = "release_quarter"
)

// QuarterResolver maps a Jira key (or fallback timestamp) to a release
// quarter label like "2026Q1". The DEVOPS project tags quarters as a
// prefix on Milestone issue summaries — the resolver looks the row up
// in `quarter_assignments` first; on miss it falls back to the
// calendar quarter of the supplied timestamp.
//
// The Milestone → quarter_label propagation pass that authoritatively
// populates `quarter_assignments` is a follow-up to W7; until it
// lands, every resolve goes through the calendar-quarter fallback,
// and the `source` field on the returned label reflects that.
type QuarterResolver struct {

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.

Warning (design/cache-growth): The cache map grows unbounded for the lifetime of the resolver. Long-running processes could accumulate thousands of entries. Consider documenting the expected memory footprint or using a bounded cache.

store storage.Store
// cache memoises one process-cycle of lookups so repeated
// resolution of the same issue inside one read doesn't hit the
// store every time. Cleared each call to ResolveCached when the
// caller opens a fresh handler context.
cache map[string]string
}

// NewQuarterResolver constructs a resolver against the given store.
// The cache is per-resolver — callers that hold the resolver across
// requests should expect a small, bounded memory footprint (one entry
// per Jira key seen since startup).
func NewQuarterResolver(store storage.Store) *QuarterResolver {
return &QuarterResolver{store: store, cache: map[string]string{}}
}

// Resolve returns the release quarter label for the supplied Jira
// key. When the key is empty, or when no row exists in
// `quarter_assignments`, the fallback timestamp is used to derive a
// calendar quarter.
//
// Source: "milestone" when the row exists in the table, "fallback"
// otherwise. A zero time means "no fallback either" — the resolver
// returns ("", "none", nil), and the caller should bucket the item
// under a "Dangling" or "Unassigned" label.
func (r *QuarterResolver) Resolve(ctx context.Context, jiraKey string, fallback time.Time) (label, source string, err error) {
if jiraKey != "" {
if cached, ok := r.cache[jiraKey]; ok {
return cached, "milestone", nil
}
row, fetchErr := r.fetchOne(ctx, jiraKey)
if fetchErr != nil {
return "", "", fetchErr
}
if row != "" {
r.cache[jiraKey] = row
return row, "milestone", nil
}
}
if fallback.IsZero() {
return "", "none", nil
}
return CalendarQuarter(fallback), "fallback", nil
}

// CalendarQuarter returns a "<YYYY>Q<1-4>" label derived from t's
// calendar quarter (Jan-Mar=Q1, Apr-Jun=Q2, Jul-Sep=Q3, Oct-Dec=Q4).
// Used by Resolve as the fallback when no Milestone assignment
// exists.
func CalendarQuarter(t time.Time) string {
q := int(t.Month()-1)/3 + 1
return fmt.Sprintf("%dQ%d", t.Year(), q)
}

func (r *QuarterResolver) fetchOne(ctx context.Context, jiraKey string) (string, error) {
d, ok := r.store.(interface {
Dialect() storage.Dialect
DB() *sql.DB
})
if !ok {
return "", nil
}
dialect := d.Dialect()
q := rebindSimple(dialect, `SELECT quarter_label FROM quarter_assignments WHERE issue_key = ?`)
var label sql.NullString
if err := d.DB().QueryRowContext(ctx, q, jiraKey).Scan(&label); err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
if !label.Valid {
return "", nil
}
return label.String, nil
}
117 changes: 117 additions & 0 deletions roadmap-planner/backend/internal/contributions/period_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
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 (
"context"
"path/filepath"
"testing"
"time"

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

func TestCalendarQuarter(t *testing.T) {
cases := []struct {
t string
want string
}{
{"2026-01-05T00:00:00Z", "2026Q1"},
{"2026-03-31T23:59:59Z", "2026Q1"},
{"2026-04-01T00:00:00Z", "2026Q2"},
{"2026-06-30T23:59:59Z", "2026Q2"},
{"2026-07-01T00:00:00Z", "2026Q3"},
{"2026-09-30T23:59:59Z", "2026Q3"},
{"2026-10-01T00:00:00Z", "2026Q4"},
{"2026-12-31T23:59:59Z", "2026Q4"},
{"2025-12-31T23:59:59Z", "2025Q4"},
}
for _, tc := range cases {
parsed, err := time.Parse(time.RFC3339, tc.t)
if err != nil {
t.Fatalf("parse %s: %v", tc.t, err)
}
if got := CalendarQuarter(parsed); got != tc.want {
t.Errorf("CalendarQuarter(%s) = %s, want %s", tc.t, got, tc.want)
}
}
}

func TestFoldWeeksByCalendarQuarter(t *testing.T) {
mk := func(year int, month time.Month, day int, prs int) Bucket {
return Bucket{
WeekStart: time.Date(year, month, day, 0, 0, 0, 0, time.UTC),
PRsMerged: prs,
}
}
in := []Bucket{
mk(2026, time.January, 5, 2),
mk(2026, time.February, 16, 3),
mk(2026, time.April, 6, 5),
mk(2025, time.December, 29, 1),
}
out := FoldWeeksByCalendarQuarter(in)
if len(out) != 3 {
t.Fatalf("expected 3 quarters, got %d: %+v", len(out), out)
}
if out[0].Period != "2025Q4" || out[0].PRsMerged != 1 {
t.Fatalf("bucket[0] = %+v, want 2025Q4/1", out[0])
}
if out[1].Period != "2026Q1" || out[1].PRsMerged != 5 {
t.Fatalf("bucket[1] = %+v, want 2026Q1/5", out[1])
}
if out[2].Period != "2026Q2" || out[2].PRsMerged != 5 {
t.Fatalf("bucket[2] = %+v, want 2026Q2/5", out[2])
}
}

// TestQuarterResolverMilestoneAndFallback exercises the W7 quarter
// resolver: a row in `quarter_assignments` wins, an empty key falls
// back to the calendar quarter of the supplied timestamp, and a
// completely dangling input returns ("", "none").
func TestQuarterResolverMilestoneAndFallback(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(dir, "qa.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
if _, err := store.DB().ExecContext(ctx,
`INSERT INTO quarter_assignments(issue_key, quarter_label, source) VALUES (?, ?, ?)`,
"DEVOPS-42014", "2026Q1", "milestone"); err != nil {
t.Fatalf("seed quarter_assignments: %v", err)
}

r := NewQuarterResolver(store)

// Milestone hit.
if label, src, err := r.Resolve(ctx, "DEVOPS-42014", time.Time{}); err != nil {
t.Fatalf("resolve milestone: %v", err)
} else if label != "2026Q1" || src != "milestone" {
t.Errorf("milestone resolve = (%s, %s), want (2026Q1, milestone)", label, src)
}

// Unknown key with timestamp falls back to calendar quarter.
ts := time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC)
if label, src, err := r.Resolve(ctx, "DEVOPS-99999", ts); err != nil {
t.Fatalf("resolve fallback: %v", err)
} else if label != "2026Q3" || src != "fallback" {
t.Errorf("fallback resolve = (%s, %s), want (2026Q3, fallback)", label, src)
}

// Empty key + zero time: nothing to bucket against.
if label, src, err := r.Resolve(ctx, "", time.Time{}); err != nil {
t.Fatalf("resolve none: %v", err)
} else if label != "" || src != "none" {
t.Errorf("none resolve = (%s, %s), want (, none)", label, src)
}
}
Loading
Loading