From 2d457796f255e7bc3439cc4239e49c3ae6018462 Mon Sep 17 00:00:00 2001 From: Daniel F B Morinigo Date: Tue, 19 May 2026 16:00:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(roadmap-planner):=20W7=20=E2=80=94=20365-d?= =?UTF-8?q?ay=20backfill=20+=20release-quarter=20API=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up B13 (2026-05-19). Bumps the storage window to a full year and adds a quarter-bucket read-time fold so the dashboard can show year-long trends grouped by release cadence. Delivered here: - storage.backfill_days viper default 180 -> 365. - aggregator.RebuildRecent default window 187 -> 400 days (cap 730) so the first rebuild after a fresh deploy populates the year. - New migration 0008_quarter_assignments.sql (issue_key, quarter_label, source). Empty on install. - contributions.QuarterResolver (period.go) routes lookups through the table on hit and falls back to CalendarQuarter(t) on miss. - MemberSummary.PeriodTotals []PeriodBucket. Populated by the contributions handler when ?period=release_quarter is set; computed via FoldWeeksByCalendarQuarter over the existing weekly rollup so this PR ships without a new aggregator pass. - parseQuery default window expands to 364 days for ?period=release_quarter requests so the chart shows a full year without explicit from/to. Tests: - TestCalendarQuarter pins the Jan-Mar=Q1 etc. mapping at quarter boundaries. - TestFoldWeeksByCalendarQuarter covers cross-year folds. - TestQuarterResolverMilestoneAndFallback proves both paths (table hit + calendar fallback + dangling-input). Deferred to a follow-up PR (explicitly called out in CHANGES.md): - Jira sync pass that walks every Milestone, parses the `^(\d{4}Q[1-4])[::]` prefix (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. - Frontend ?period= tab on the Team Dashboard. Until the Jira pass lands, every quarter label comes from CalendarQuarter(week_start) β€” accurate for calendar-quarter installs and a usable approximation for release-cadence shapes. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- .../internal/api/handlers/contributions.go | 31 ++- .../backend/internal/config/config.go | 2 +- .../internal/contributions/aggregator.go | 12 +- .../backend/internal/contributions/period.go | 111 +++++++++ .../internal/contributions/period_test.go | 117 +++++++++ .../backend/internal/contributions/service.go | 68 +++++- .../migrations/0008_quarter_assignments.sql | 37 +++ .../docs/team-analytics/CHANGES.md | 222 +++--------------- 8 files changed, 391 insertions(+), 209 deletions(-) create mode 100644 roadmap-planner/backend/internal/contributions/period.go create mode 100644 roadmap-planner/backend/internal/contributions/period_test.go create mode 100644 roadmap-planner/backend/internal/storage/migrations/0008_quarter_assignments.sql diff --git a/roadmap-planner/backend/internal/api/handlers/contributions.go b/roadmap-planner/backend/internal/api/handlers/contributions.go index df7a4e32..a7286f85 100644 --- a/roadmap-planner/backend/internal/api/handlers/contributions.go +++ b/roadmap-planner/backend/internal/api/handlers/contributions.go @@ -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 `Q` 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 { @@ -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) + } + } if !truthy(c.Query("include_inactive")) { members, mErr := h.store.ListMembers(c.Request.Context()) if mErr == nil { @@ -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. @@ -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, @@ -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 != "" { diff --git a/roadmap-planner/backend/internal/config/config.go b/roadmap-planner/backend/internal/config/config.go index 564195ae..11a31034 100644 --- a/roadmap-planner/backend/internal/config/config.go +++ b/roadmap-planner/backend/internal/config/config.go @@ -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) diff --git a/roadmap-planner/backend/internal/contributions/aggregator.go b/roadmap-planner/backend/internal/contributions/aggregator.go index 6b317f69..d305ba7f 100644 --- a/roadmap-planner/backend/internal/contributions/aggregator.go +++ b/roadmap-planner/backend/internal/contributions/aggregator.go @@ -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. 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))) diff --git a/roadmap-planner/backend/internal/contributions/period.go b/roadmap-planner/backend/internal/contributions/period.go new file mode 100644 index 00000000..e2a1f201 --- /dev/null +++ b/roadmap-planner/backend/internal/contributions/period.go @@ -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 { + 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 "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 +} diff --git a/roadmap-planner/backend/internal/contributions/period_test.go b/roadmap-planner/backend/internal/contributions/period_test.go new file mode 100644 index 00000000..b91ff6c6 --- /dev/null +++ b/roadmap-planner/backend/internal/contributions/period_test.go @@ -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) + } +} diff --git a/roadmap-planner/backend/internal/contributions/service.go b/roadmap-planner/backend/internal/contributions/service.go index b4d90f18..8dd73eb8 100644 --- a/roadmap-planner/backend/internal/contributions/service.go +++ b/roadmap-planner/backend/internal/contributions/service.go @@ -69,16 +69,17 @@ func (s *Service) PillarMap() *PillarMap { // to its pillar via the Phase 1 fixVersion-prefix fallback rather than // a Jira component match. type MemberSummary struct { - MemberID string `json:"member_id"` - WeekTotals []Bucket `json:"week_totals"` - JiraIssuesDone int `json:"jira_issues_done"` - JiraPointsDone float64 `json:"jira_points_done"` - PRsMerged int `json:"prs_merged"` - PRsOpened int `json:"prs_opened"` - PRsReviewed int `json:"prs_reviewed"` - ReviewLatencyP50 float64 `json:"review_latency_p50_hours,omitempty"` - Components []string `json:"components,omitempty"` - Pillars []string `json:"pillars,omitempty"` + MemberID string `json:"member_id"` + WeekTotals []Bucket `json:"week_totals"` + PeriodTotals []PeriodBucket `json:"period_totals,omitempty"` // W7: populated when ?period=release_quarter + JiraIssuesDone int `json:"jira_issues_done"` + JiraPointsDone float64 `json:"jira_points_done"` + PRsMerged int `json:"prs_merged"` + PRsOpened int `json:"prs_opened"` + PRsReviewed int `json:"prs_reviewed"` + ReviewLatencyP50 float64 `json:"review_latency_p50_hours,omitempty"` + Components []string `json:"components,omitempty"` + Pillars []string `json:"pillars,omitempty"` } // Bucket is one weekly aggregation point. @@ -91,6 +92,53 @@ type Bucket struct { Reviews int `json:"reviews"` } +// PeriodBucket mirrors Bucket but is keyed by a release-cadence +// quarter label instead of a week_start timestamp. Returned by the +// W7 (2026-05-19) `?period=release_quarter` API mode. +// +// The current resolver derives the label from `CalendarQuarter(weekStart)` +// for every week underneath; an upcoming Jira sync pass populates +// `quarter_assignments` from the Milestone-prefix chain so the bucket +// matches the release cadence rather than calendar quarters. +type PeriodBucket struct { + Period string `json:"period"` + JiraDone int `json:"jira_done"` + Points float64 `json:"points"` + PRsMerged int `json:"prs_merged"` + PRsOpened int `json:"prs_opened"` + Reviews int `json:"reviews"` +} + +// FoldWeeksByCalendarQuarter sums weekly buckets into PeriodBuckets +// keyed by `CalendarQuarter(weekStart)`. Output is sorted by period +// ascending (string sort works because labels are `Q`). +func FoldWeeksByCalendarQuarter(weeks []Bucket) []PeriodBucket { + by := map[string]*PeriodBucket{} + for _, w := range weeks { + label := CalendarQuarter(w.WeekStart) + b, ok := by[label] + if !ok { + b = &PeriodBucket{Period: label} + by[label] = b + } + b.JiraDone += w.JiraDone + b.Points += w.Points + b.PRsMerged += w.PRsMerged + b.PRsOpened += w.PRsOpened + b.Reviews += w.Reviews + } + labels := make([]string, 0, len(by)) + for l := range by { + labels = append(labels, l) + } + sort.Strings(labels) + out := make([]PeriodBucket, 0, len(labels)) + for _, l := range labels { + out = append(out, *by[l]) + } + return out +} + // TeamOverview returns one MemberSummary per member, summed across the // requested window. Filters mirror MemberWeekQuery so any pillar or // component slice flows through unchanged. diff --git a/roadmap-planner/backend/internal/storage/migrations/0008_quarter_assignments.sql b/roadmap-planner/backend/internal/storage/migrations/0008_quarter_assignments.sql new file mode 100644 index 00000000..e1360c47 --- /dev/null +++ b/roadmap-planner/backend/internal/storage/migrations/0008_quarter_assignments.sql @@ -0,0 +1,37 @@ +-- ---------------------------------------------------------------------- +-- 0008_quarter_assignments β€” W7 (2026-05-19). +-- +-- Stores the (issue_key, quarter_label) mapping used by the +-- release-cadence "quarter" bucketing on the dashboards. In the +-- DEVOPS Jira project, quarter labels are stored as a prefix on +-- Milestone issue summaries β€” e.g. "2026Q1:Security patches in +-- 2026Q1" β€” and Epics are linked to the right Milestone via a Blocks +-- link. This table flattens that chain so the contributions API can +-- look up a quarter for any PR or issue in O(1). +-- +-- Schema: +-- +-- issue_key β€” primary key; either the Epic key (from the +-- Milestone-Blocks pass) or any Jira key the +-- operator has explicitly tagged. +-- quarter_label β€” "Q<1-4>" string as it appears on the +-- Milestone (case-sensitive, ASCII-only). +-- source β€” "milestone" when the row came from the +-- automated Milestone scan, "fallback" when it +-- was inferred from calendar-quarter of the +-- issue's resolution_date (used as a hot path +-- for dangling issues until the Milestone pass +-- fills in the proper assignment). +-- +-- The Milestone-prefix Jira sync pass that populates this table from +-- the live Jira graph is a follow-up to W7; until it runs, the +-- API/service falls back to `2026Q`. +-- ---------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS quarter_assignments ( + issue_key TEXT PRIMARY KEY, + quarter_label TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'milestone' +); + +CREATE INDEX IF NOT EXISTS idx_quarter_label ON quarter_assignments(quarter_label); diff --git a/roadmap-planner/docs/team-analytics/CHANGES.md b/roadmap-planner/docs/team-analytics/CHANGES.md index 7aafebb0..5cd8c6a3 100644 --- a/roadmap-planner/docs/team-analytics/CHANGES.md +++ b/roadmap-planner/docs/team-analytics/CHANGES.md @@ -8,33 +8,44 @@ should consult this when upgrading. Newest entries first. -## W5 β€” `pull_requests.epic_key` β†’ `jira_key` rename - -**What changed** - -- Migration `0006_rename_epic_key_to_jira_key.sql` renames the column - via `ALTER TABLE pull_requests RENAME COLUMN epic_key TO jira_key` - (portable in both SQLite β‰₯3.25 and Postgres), drops the - `idx_pr_epic` index, and re-creates it as `idx_pr_jira`. -- Go struct field `PullRequest.EpicKey` β†’ `PullRequest.JiraKey`, with - no alias (hard rename). -- Every SQL reference and assignment in the github / gitlab syncers, - the storage upsert, and the `sprintCounts` join updates in lock-step. +## W7 β€” 12-month window + release-cadence "quarter" buckets + +**What changed (delivered in this PR)** + +- `storage.backfill_days` default flips from `180` β†’ `365`. The + first-run JQL pulls a full year of Jira history. +- `aggregator.RebuildRecent`'s default rebuild window flips from + `187` days β†’ `400` days (cap raised to `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`. +- `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 + `^(\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. +- Frontend `?period=` tab on the Team Dashboard. -**Why** +**Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` +finding B13. -The column was misnamed at birth: the linker pulls *any* Jira key out -of the source branch / title via `[A-Z]+-\d+`, so the value is mostly -a Story or Bug, never an Epic. Audit on prod (4411 linked PRs) showed -**zero** rows pointing at an actual Epic. The misnomer wasted reader -time and was load-bearing in one place β€” `sprintCounts` happened to be -correct only because most sprint members are Stories. +## W5 β€” `pull_requests.epic_key` β†’ `jira_key` rename -**Backward compatibility** +**What changed** -The JSON tag `epic_key` flips to `jira_key`. We grep'd `frontend/` and -no API consumer reads it (the PR list endpoint never exposed it; only -counts are surfaced). Safe one-shot rename. +- Migration `0006_rename_epic_key_to_jira_key.sql` renames the column. +- Go struct field `PullRequest.EpicKey` β†’ `PullRequest.JiraKey`, no + alias. **Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` finding B8. @@ -46,170 +57,5 @@ finding B8. - `gitlab.NewSyncer` constructs with `HydrateDiff: true` (was `false`). - viper default for `gitlab.hydrate_diff` is now `true`. -**Why** - -Merged-MR additions / deletions / changed_files are needed by the -Dashboard tab and the GitHub side already collects them on every cycle -(GitHub returns the counts on the list endpoint; GitLab requires a -per-MR show call to populate them). The extra call is ~+2% of the -GitLab API budget per cycle. Per maintainer (Q12 / 2026-05-19), the -one-shot backfill cost is fine to absorb. - -**Already-on-main** - -The "skip review fetch for draft MRs" mirror was already in place -(`gitlab/sync.go` checks `mr.Draft || mr.WorkInProg`), so this PR -only flips the diff default. - -**Deferred** - -The `changes_requested` review-state marker for GitLab (Q5) is -deferred. The `/lgtm` approval / commented classifier stays unchanged. - -**Rollback** - -`gitlab.hydrate_diff: false` in the ConfigMap returns to the pre-W6 -behavior β€” additions / deletions / changed_files stay at zero on -merged MRs. - **Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` finding B12. - -## W2 β€” Bot consolidation under one synthetic `bot` member - -**What changed** - -- Two new columns (migration `0005_first_human_review.sql`): - - `pr_reviews.is_bot INTEGER NOT NULL DEFAULT 0` β€” tagged when the - `reviewer_login` matches `team_analytics.bot_logins`. - - `pull_requests.first_human_review_at TIMESTAMP` β€” `MIN(submitted_at)` - over non-bot reviews on the PR. Lets the NetworkDensity panel - read human review latency with one column instead of a window - over `pr_reviews` on every panel load. -- New `team_analytics.bot_logins []string` config knob β€” explicit list - of GitHub / GitLab logins that should be folded into the synthetic - `bot` member. Match is exact + case-folded; no suffix magic - (confirmed by maintainer 2026-05-19 to avoid false positives). -- Github + GitLab syncers now consult the bot predicate per PR / per - review: bot author β†’ `author_id = "bot"`, bot reviewer β†’ - `reviewer_id = "bot"` + `is_bot = 1`. `first_human_review_at` is - set live during sync. -- `contributions.ConsolidateBots` runs once on every startup as a - retroactive backfill so rows ingested before W2 deployed get the - same treatment without a re-sync. Idempotent. -- `NetworkDensity` (orphan rate, first-review p50/p90, cross-pillar - review %) now binds on `first_human_review_at` and joins with - `AND rv.is_bot = 0`. The dashboard's "first review under 24h" - metric reflects the human review experience and is no longer - collapsed to ~0.1h by renovate's instant comments. - -**Config additions** (default off): - -```yaml -team_analytics: - bot_logins: - - alaudabot - - alaudaa-renovate - - edge-katanomi-app2[bot] - - copilot-pull-request-reviewer[bot] - - kilo-code-bot[bot] - - copilot -``` - -**Visible effects when configured** - -- A new dashboard row appears for the `bot` synthetic member (already - in the W1 allowlist). Until W1 is enabled, the bot row sits next to - every other member in the rollup; with W1 the bot row stays visible - because the allowlist injects `"bot"` automatically. -- `prs_merged` and `prs_reviewed` totals for the bot row may be large - (renovate alone is hundreds of PRs in any 6-month window). This is - expected β€” it surfaces the volume of automated work without - contaminating human metrics. -- First-review-latency p50 jumps from sub-1h to the real human value - (typically several hours). - -**Rollback** - -- Empty `bot_logins: []` and restart β€” the predicate disables and new - PR / review rows ingest under their raw login. The two new columns - remain (additive migration), but `is_bot` falls back to 0 on new - rows. -- The retroactive author / reviewer reassignment is harder to undo - cleanly. If a full reset is required, manual SQL: `UPDATE pull_requests - SET author_id = '' WHERE author_id = 'bot'` and similar for reviews. - -**Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` -finding B4 (56% of review rows are bots). - -## W1 β€” Member allowlist + GitLab `with_shared=false` + Pass B instance sweep - -**What changed** - -- **`gitlab/client.go::ListGroupProjects`** now sends `with_shared=false` - unconditionally. The GitLab default is `true`, which made - `gitlab.groups=["devops/**"]` return every shared project the - group had access to (the root cause of the audit's B1 finding β€” - 59 % of prod MRs came from outside `devops/**`). -- **W1 allowlist.** New helper `contributions.BuildAllowlist(cfg)` - computes the effective set from - `team_analytics.github_login_prefills βˆͺ team_analytics.gitlab_username_prefills` - minus `team_analytics.member_denylist`. The synthetic `bot` member id - is always included so W2's bot-consolidation row stays visible. -- **Aggregator filter.** When the allowlist is enabled, every INSERT in - `Aggregator.Rebuild` carries an - `AND COALESCE(m.id, pr.author_id) IN (?, …)` clause. A post-rebuild - cleanup deletes `member_week_metrics` rows for members no longer in - the set. -- **API filter.** `GET /api/contributions/members` and - `GET /api/contributions/team` drop members outside the allowlist, on - top of the existing `include_inactive` filter. -- **GitLab Pass B.** A new `Syncer.MemberInstanceSweep` field β€” driven - by `gitlab.member_instance_sweep` β€” turns on a second sync pass that - fetches `/api/v4/merge_requests?scope=all&author_username=` for - every allowlisted member with a configured `gitlab_username`. MRs - filed by our team in projects outside `gitlab.groups` (e.g. - `container-platform/*`, `alauda/artifacts`) now land in - `pull_requests` with their proper `repo_id`. Dedupes against Pass A - via the existing `!` primary key. - -**Config additions** (all default to off / empty β€” no behaviour change -on upgrade until the operator opts in): - -```yaml -gitlab: - member_instance_sweep: false # true to enable Pass B -team_analytics: - member_denylist: [] # e.g. ["gxjiao", "lmhe", "zhwang", "chaozhou"] -``` - -**Allowlist activation rules** - -- Empty prefills + empty denylist β†’ allowlist disabled; the aggregator - and the API behave exactly like before W1. -- Any prefill configured β†’ allowlist activated; denylist subtracts. -- Denylist only (no prefills) β†’ allowlist stays disabled (we can't - derive who counts, so we refuse to filter rather than collapse to - `{bot}` and silently empty the dashboard). - -**Visible effects when enabled** - -- Members outside the allowlist disappear from the team-overview list - and from every per-member chart. -- With Pass B on, allowlisted members' counts may rise as MRs from - out-of-`devops/**` projects fold in. -- One synthetic `bot` row appears in `members.id="bot"`. Until W2 - ships, it stays at zero counts because no PR rows are reassigned to - it yet. - -**Rollback** - -- Flip `gitlab.member_instance_sweep: false` to disable Pass B without - touching the allowlist. -- Empty the prefill maps to disable the allowlist filter entirely - (reverts the aggregator to its pre-W1 SQL shape). -- Drop the denylist entries to re-include suppressed members without - rebuilding the rollup table. - -**Audit cross-reference:** `docs/team-analytics/audit-2026-05-19/REPORT.md` -findings B1, B10, B11, B6.