-
Notifications
You must be signed in to change notification settings - Fork 3
feat(roadmap-planner): W7 — 365-day backfill + release-quarter API mode #194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion ( |
||
| 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))) | ||
|
|
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning ( |
||
| 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 | ||
| } | ||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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 usingcontributions.PeriodReleaseQuarterconstant instead of string literal"release_quarter"for maintainability.