Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (h *ContributionsHandler) MemberDetail(c *gin.Context) {
"jira_issues_done": summary.JiraIssuesDone,
"jira_points_done": summary.JiraPointsDone,
"prs_merged": summary.PRsMerged,
"prs_opened": summary.PRsOpened,
"prs_reviewed": summary.PRsReviewed,
"review_latency_p50_hours": summary.ReviewLatencyP50,
}
Expand Down
32 changes: 32 additions & 0 deletions roadmap-planner/backend/internal/contributions/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,38 @@ func (a *Aggregator) Rebuild(ctx context.Context, from, to time.Time) error {
return fmt.Errorf("aggregate PRs: %w", err)
}

// PRs/MRs opened, by author × week. Mirrors the merged pass but
// buckets on created_at — the dashboard's Inflow vs Outflow panel
// reads opened-vs-merged together to spot review-queue buildup.
openedSQL := rebind(fmt.Sprintf(`
INSERT INTO member_week_metrics (member_id, week_start, pillar_id, component, prs_opened)
SELECT
COALESCE(m.id, pr.author_id) AS member_id,
%s AS week_start,
'' AS pillar_id,
'' AS component,
COUNT(*) AS prs_opened
FROM pull_requests pr
LEFT JOIN members m
ON (pr.source = 'github'
AND m.github_login IS NOT NULL
AND m.github_login <> ''
AND LOWER(m.github_login) = pr.author_login)
OR (pr.source = 'gitlab'
AND m.gitlab_username IS NOT NULL
AND m.gitlab_username <> ''
AND LOWER(m.gitlab_username) = pr.author_login)
WHERE COALESCE(m.id, pr.author_id) IS NOT NULL
AND pr.created_at >= ?
AND pr.created_at < ?
GROUP BY 1, 2
ON CONFLICT(member_id, week_start, pillar_id, component) DO UPDATE SET
prs_opened = excluded.prs_opened`, dialect.WeekStart("pr.created_at")))

if _, err := db.ExecContext(ctx, openedSQL, from, to); err != nil {
return fmt.Errorf("aggregate opened PRs: %w", err)
}

// PRs/MRs reviewed, by reviewer × week. Same resolution priority as
// the merge aggregation above — the JOIN branches on rv.source so
// editing either github_login or gitlab_username retroactively
Expand Down
111 changes: 111 additions & 0 deletions roadmap-planner/backend/internal/contributions/pillar_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ type PillarMap struct {
repoGlobs []repoGlob
// component name (lowercased) → pillars.
componentToPillars map[string][]string
// pillar name → its configured component names (lowercased), in
// config declaration order. Used by ComponentsFor's longest-prefix
// match to derive a component from a fixVersion when the issue has
// no components set. Forward index of componentToPillars; built once
// at construction time.
pillarToComponents map[string][]string
// fixVersion prefix/glob → list of pillars that claim it. Used as the
// fallback when an issue has no components set; matched in declaration
// order so duplicate-pattern entries collapse predictably.
Expand All @@ -62,6 +68,7 @@ type versionPrefix struct {
func NewPillarMap(cfg config.TeamAnalytics) *PillarMap {
pm := &PillarMap{
componentToPillars: map[string][]string{},
pillarToComponents: map[string][]string{},
}
if len(cfg.Pillars) == 0 {
return pm
Expand All @@ -87,6 +94,7 @@ func NewPillarMap(cfg config.TeamAnalytics) *PillarMap {
continue
}
pm.componentToPillars[c] = append(pm.componentToPillars[c], name)
pm.pillarToComponents[name] = append(pm.pillarToComponents[name], c)
}
for _, v := range mp.VersionPrefixes {
v = strings.ToLower(strings.TrimSpace(v))
Expand Down Expand Up @@ -262,6 +270,109 @@ func (p *PillarMap) PillarsForComponents(components []string) []string {
return out
}

// PillarsFor runs the components→fixVersion fallback chain that the
// per-issue aggregators (PillarThroughput, attributionByMember) all
// repeat: try PillarsForComponents on the issue's own components first,
// then fall back to PillarsForVersions on its fixVersions. Returns the
// union of pillars matched in step 1 OR step 2 — never both, since
// step 2 only runs when step 1 returned nothing. Empty result means
// neither matcher hit; the caller decides whether to bucket under
// "Unassigned" or skip.
func (p *PillarMap) PillarsFor(directComps, versions []string) []string {
if p == nil {
return nil
}
if pillars := p.PillarsForComponents(directComps); len(pillars) > 0 {
return pillars
}
return p.PillarsForVersions(versions)
}

// ComponentsFor derives a component-name set for one issue using the
// same components→fixVersion fallback chain. Step 1 returns the issue's
// own components (trimmed and de-duped, original case preserved). Step 2
// runs only when step 1 is empty: for every fixVersion we find which
// pillar(s) claim it (via the same PillarsForVersions match rules) and
// pick the *longest* component name from each matched pillar's
// configured components[] that is itself a versionMatches() prefix of
// the fixVersion. Longest wins so e.g. components `[tekton, tekton-cli]`
// against version `tekton-cli-v1.0.0` returns `tekton-cli`, not both.
//
// Empty return means neither step produced anything — useful as a
// signal that this issue's components-touched cell should stay blank.
//
// Step-2 outputs are lowercased (matching the configured-component
// storage), step-1 outputs preserve the case Jira returned.
func (p *PillarMap) ComponentsFor(directComps, versions []string) []string {
if p == nil {
return nil
}
// Step 1: issue's own components win when present.
if out := dedupNonEmpty(directComps); len(out) > 0 {
return out
}
if len(p.versionPrefixes) == 0 || len(versions) == 0 || len(p.pillarToComponents) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, v := range versions {
key := strings.ToLower(strings.TrimSpace(v))
if key == "" {
continue
}
matchedPillars := map[string]bool{}
for _, vp := range p.versionPrefixes {
if !versionMatches(vp.pattern, key) {
continue
}
for _, pl := range vp.pillars {
matchedPillars[pl] = true
}
}
for pillar := range matchedPillars {

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 (bug/null-pointer): p.pillarToComponents[pillar] may be nil if the pillar matched only via versionPrefixes but has no configured components[]. Calling range over nil causes a runtime panic.

Suggested change
for pillar := range matchedPillars {
for pillar := range matchedPillars {
if p.pillarToComponents[pillar] == nil {
continue
}
best := ""
for _, c := range p.pillarToComponents[pillar] {

best := ""
for _, c := range p.pillarToComponents[pillar] {
if !versionMatches(c, key) {
continue
}
if len(c) > len(best) {
best = c
}
}
if best == "" || seen[best] {
continue
}
seen[best] = true
out = append(out, best)
}
}
return out
}

// dedupNonEmpty trims, drops empty entries, and de-dupes case-insensitively
// while preserving the first occurrence's original case.
func dedupNonEmpty(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, s := range in {
t := strings.TrimSpace(s)
if t == "" {
continue
}
key := strings.ToLower(t)
if seen[key] {
continue
}
seen[key] = true
out = append(out, t)
}
return out
}

// compactRepoGlobs merges duplicate pattern entries so we don't double-count
// when the same glob appears under multiple pillars.
func compactRepoGlobs(in []repoGlob) []repoGlob {
Expand Down
107 changes: 107 additions & 0 deletions roadmap-planner/backend/internal/contributions/pillar_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,113 @@ func TestPillarMap_OrderIsStableAlpha(t *testing.T) {
}
}

func TestPillarMap_PillarsFor_ComponentsWin(t *testing.T) {
pm := contributions.NewPillarMap(config.TeamAnalytics{
Pillars: map[string]config.PillarMapping{
"CI/CD": {Components: []string{"Tekton"}, VersionPrefixes: []string{"tektoncd-operator"}},
"Tool Deployment": {VersionPrefixes: []string{"harbor-ce-operator"}},
},
})
// Components match → version fallback should NOT run, even if
// versions would also match a different pillar.
got := pm.PillarsFor([]string{"Tekton"}, []string{"harbor-ce-operator-v2.14.3"})
if !equalUnordered(got, []string{"CI/CD"}) {
t.Fatalf("components-win failed: %v", got)
}
}

func TestPillarMap_PillarsFor_VersionFallback(t *testing.T) {
pm := contributions.NewPillarMap(config.TeamAnalytics{
Pillars: map[string]config.PillarMapping{
"CI/CD": {Components: []string{"Tekton"}, VersionPrefixes: []string{"tektoncd-operator"}},
},
})
got := pm.PillarsFor(nil, []string{"tektoncd-operator-v4.11.0"})
if !equalUnordered(got, []string{"CI/CD"}) {
t.Fatalf("version-fallback failed: %v", got)
}
// Empty components AND empty versions → empty result.
if got := pm.PillarsFor(nil, nil); len(got) != 0 {
t.Fatalf("want empty, got %v", got)
}
}

func TestPillarMap_ComponentsFor_DirectComponentsWin(t *testing.T) {
pm := contributions.NewPillarMap(config.TeamAnalytics{
Pillars: map[string]config.PillarMapping{
"CI/CD": {Components: []string{"tektoncd-operator"}, VersionPrefixes: []string{"tektoncd-operator"}},
},
})
got := pm.ComponentsFor([]string{"Tekton", " ", "Tekton", "Triggers"}, []string{"tektoncd-operator-v4.11.0"})
// Verbatim case preserved, dedup case-insensitive, empties dropped,
// and the version fallback does NOT run when direct components are
// present.
if !equalUnordered(got, []string{"Tekton", "Triggers"}) {
t.Fatalf("direct-components-win failed: %v", got)
}
}

func TestPillarMap_ComponentsFor_LongestPrefixWins(t *testing.T) {
pm := contributions.NewPillarMap(config.TeamAnalytics{
Pillars: map[string]config.PillarMapping{
"CI/CD": {
Components: []string{"tekton", "tekton-cli", "tektoncd-operator"},
VersionPrefixes: []string{"tektoncd-operator", "tekton-cli"},
},
},
})
cases := []struct {
name string
versions []string
want []string
}{
{"longest match — full operator", []string{"tektoncd-operator-v4.11.0"}, []string{"tektoncd-operator"}},
{"longest match — cli", []string{"tekton-cli-v1.0.0"}, []string{"tekton-cli"}},
{"multi-version dedupes", []string{"tektoncd-operator-v4.11.0", "tektoncd-operator-v4.10.0"}, []string{"tektoncd-operator"}},
{"no version match", []string{"unknown-v1.0.0"}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pm.ComponentsFor(nil, tc.versions)
if !equalUnordered(got, tc.want) {
t.Fatalf("ComponentsFor(nil, %v) = %v, want %v", tc.versions, got, tc.want)
}
})
}
}

func TestPillarMap_ComponentsFor_PillarMatchedByVersionPrefixOnly(t *testing.T) {
// Pillar matches the version (via versionPrefixes) but its
// components[] doesn't contain anything that's a prefix of the
// version. Expected: no derived component (we don't invent one,
// and we don't return the whole components[] list).
pm := contributions.NewPillarMap(config.TeamAnalytics{
Pillars: map[string]config.PillarMapping{
"CI/CD": {
Components: []string{"tekton"},
VersionPrefixes: []string{"tektoncd-operator"},
},
},
})
got := pm.ComponentsFor(nil, []string{"tektoncd-operator-v4.11.0"})
// "tekton" is NOT a versionMatches() prefix of "tektoncd-operator-v4.11.0"
// (would need "tekton-" or "tektonv" boundary). So we return nothing
// rather than guessing.
if len(got) != 0 {
t.Fatalf("want empty (no longest-prefix candidate), got %v", got)
}
}

func TestPillarMap_ComponentsFor_Empty(t *testing.T) {
pm := contributions.NewPillarMap(config.TeamAnalytics{})
if got := pm.ComponentsFor(nil, nil); len(got) != 0 {
t.Fatalf("want empty, got %v", got)
}
if got := pm.ComponentsFor([]string{"X"}, nil); !equalUnordered(got, []string{"X"}) {
t.Fatalf("direct comps should pass through even without config, got %v", got)
}
}

func equalUnordered(a, b []string) bool {
if len(a) != len(b) {
return false
Expand Down
12 changes: 6 additions & 6 deletions roadmap-planner/backend/internal/contributions/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type MemberSummary struct {
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"`
Expand All @@ -86,6 +87,7 @@ type Bucket struct {
JiraDone int `json:"jira_done"`
Points float64 `json:"points"`
PRsMerged int `json:"prs_merged"`
PRsOpened int `json:"prs_opened"`
Reviews int `json:"reviews"`
}

Expand All @@ -110,6 +112,7 @@ func (s *Service) TeamOverview(ctx context.Context, q storage.MemberWeekQuery) (
ms.JiraIssuesDone += r.JiraIssuesDone
ms.JiraPointsDone += r.JiraPointsDone
ms.PRsMerged += r.PRsMerged
ms.PRsOpened += r.PRsOpened
ms.PRsReviewed += r.PRsReviewed
// We pick the latest non-nil latency seen as a representative;
// proper aggregation across weeks happens in the Aggregator.
Expand All @@ -126,6 +129,7 @@ func (s *Service) TeamOverview(ctx context.Context, q storage.MemberWeekQuery) (
bk.JiraDone += r.JiraIssuesDone
bk.Points += r.JiraPointsDone
bk.PRsMerged += r.PRsMerged
bk.PRsOpened += r.PRsOpened
bk.Reviews += r.PRsReviewed
}

Expand Down Expand Up @@ -227,15 +231,11 @@ func (s *Service) attributionByMember(ctx context.Context, q storage.MemberWeekQ
mp[c]++
}
}
// Same matcher chain as service_extras.go's pillar bucket
// aggregator: components first, fixVersions as the Phase 1
// Shared chain: components-first, fixVersions as the Phase 1
// fallback. An issue contributes to every pillar it matches
// (multi-pillar components / shared version_prefixes are
// honored), and contributes nothing if neither matcher hits.
matched := pm.PillarsForComponents(comps)
if len(matched) == 0 {
matched = pm.PillarsForVersions(vers)
}
matched := pm.PillarsFor(comps, vers)
if len(matched) == 0 {
continue
}
Expand Down
Loading
Loading