diff --git a/src/group.test.ts b/src/group.test.ts index f9bff00..b2b32f2 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { applyTeamPick, + consolidateTeamHierarchy, flattenTeamSections, groupByTeamHierarchy, groupByTeamPrefix, @@ -323,6 +324,104 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = }); }); +// ─── consolidateTeamHierarchy ───────────────────────────────────────────────── + +describe("consolidateTeamHierarchy", () => { + it("collapses a single-branch chain into one heading with an 'including' suffix", () => { + const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard"])]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated).toHaveLength(1); + expect(consolidated[0].label).toBe("gamme-client (including squad-dashboard)"); + expect(consolidated[0].level).toBe(0); + expect(consolidated[0].children ?? []).toHaveLength(0); + expect(consolidated[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + }); + + it("collapses a 3-level single-branch chain into one heading", () => { + const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard", "chapter-fe"])]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-", "chapter-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated[0].label).toBe("gamme-client (including squad-dashboard, chapter-fe)"); + expect(consolidated[0].children ?? []).toHaveLength(0); + }); + + it("reads a nested 'other' bucket as 'unset' in the suffix", () => { + const groups = [makeGroup("org/a", ["gamme-client"])]; // no squad- team → nested "other" + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated[0].label).toBe("gamme-client (including unset)"); + }); + + it("does NOT collapse a level where a node has 2+ children", () => { + const groups = [ + makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/b", ["gamme-client", "squad-billing"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated[0].label).toBe("gamme-client"); + const childLabels = (consolidated[0].children ?? []).map((c) => c.label).toSorted(); + expect(childLabels).toEqual(["squad-billing", "squad-dashboard"]); + expect(consolidated[0].children!.every((c) => c.level === 1)).toBe(true); + }); + + it("leaves a leaf section (no children) unchanged", () => { + const groups = [makeGroup("org/a", ["squad-front"])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated[0].label).toBe("squad-front"); + expect(consolidated[0].level).toBe(0); + }); + + it("stops collapsing before a child that itself forks into 2+ children", () => { + const groups = [ + makeGroup("org/a", ["gamme-x"]), + makeGroup("org/b", ["gamme-x-y"]), + makeGroup("org/c", ["gamme-x-y-c1"]), + makeGroup("org/d", ["gamme-x-y-c2"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated).toHaveLength(1); + // "gamme-x-y" is the fork point (2 children) — it must remain its own + // heading rather than being absorbed into "gamme-x"'s suffix. + expect(consolidated[0].label).toBe("gamme-x"); + expect(consolidated[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(consolidated[0].children).toHaveLength(1); + const fork = consolidated[0].children![0]; + expect(fork.label).toBe("gamme-x-y"); + expect(fork.groups.map((g) => g.repoFullName)).toEqual(["org/b"]); + const forkChildLabels = (fork.children ?? []).map((c) => c.label).toSorted(); + expect(forkChildLabels).toEqual(["gamme-x-y-c1", "gamme-x-y-c2"]); + }); + + it("accumulates groups from every merged node, not just the deepest one", () => { + const groups = [ + makeGroup("org/a", ["gamme-x"]), + makeGroup("org/b", ["gamme-x-y"]), + makeGroup("org/c", ["gamme-x-y-z"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + const consolidated = consolidateTeamHierarchy(sections); + expect(consolidated[0].label).toBe("gamme-x (including gamme-x-y, gamme-x-y-z)"); + expect(consolidated[0].groups.map((g) => g.repoFullName).toSorted()).toEqual([ + "org/a", + "org/b", + "org/c", + ]); + expect(consolidated[0].children ?? []).toHaveLength(0); + }); + + it("is a pure function — does not mutate the input tree", () => { + const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard"])]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const before = JSON.stringify(sections); + consolidateTeamHierarchy(sections); + expect(JSON.stringify(sections)).toBe(before); + }); +}); + // ─── flattenTeamSections ────────────────────────────────────────────────────── describe("flattenTeamSections", () => { diff --git a/src/group.ts b/src/group.ts index 8b8958e..40de18e 100644 --- a/src/group.ts +++ b/src/group.ts @@ -214,6 +214,65 @@ function assignLevels(node: TeamSection, lvl: number): TeamSection { return node; } +// ─── Advanced consolidated rendering ────────────────────────────────────────── + +/** + * Collapses chains of single-child nesting in a `groupByTeamHierarchy` tree + * into one node, so a run of unambiguous nesting (a parent with exactly one + * child, that child with exactly one child, …) renders as a single heading + * with an "(including …)" suffix listing the collapsed labels, instead of + * one heading per level. A node whose next level has 0 or 2+ children is + * left as-is at that point (only unambiguous single-branch chains collapse); + * a child that itself forks into 2+ children stops the collapse *before* it + * so the fork point remains its own heading rather than disappearing into + * the suffix. + * + * `groups` from every node absorbed into the collapsed heading (the root and + * each merged intermediate) are accumulated — overlap-nested parents can own + * repos directly (see `TeamSection`), and those must not be dropped. + * + * The `"other"` label reads as `"unset"` inside the suffix (e.g. `"gamme- + * lead-client (including p1, unset)"`), matching how an unassigned bucket + * reads in prose, without changing the underlying section's `label`. + * + * Pure function — no mutation of the input tree; `level` is recomputed on + * the resulting (shallower) tree. + */ +export function consolidateTeamHierarchy(sections: TeamSection[]): TeamSection[] { + return sections.map((s) => assignLevels(consolidateNode(s), s.level ?? 0)); +} + +function consolidateNode(node: TeamSection): TeamSection { + const collapsedLabels: string[] = []; + let current = node; + let groups = node.groups; + while (current.children && current.children.length === 1) { + const only = current.children[0]; + // A forking grandchild must remain its own heading — stop before it. + if (only.children && only.children.length > 1) break; + collapsedLabels.push(only.label === "other" ? "unset" : only.label); + groups = [...groups, ...only.groups]; + current = only; + } + + const label = + collapsedLabels.length > 0 + ? `${node.label} (including ${collapsedLabels.join(", ")})` + : node.label; + + const children = + current.children && current.children.length > 0 + ? current.children.map((c) => consolidateNode(c)) + : undefined; + + return { + label, + groups, + level: node.level, + ...(children ? { children } : {}), + }; +} + /** * Recursively drops an empty `children` array so the field is only present * when a section actually has nested sub-sections, matching `TeamSection`'s