From 26125d1046379af9eb16a9e928a18de4074f05d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 22:28:52 +0200 Subject: [PATCH 1/2] Add consolidateTeamHierarchy: collapse single-branch chains into one heading --- src/group.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++ src/group.ts | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/group.test.ts b/src/group.test.ts index f9bff00..0af8cad 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,65 @@ 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("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..1fd823b 100644 --- a/src/group.ts +++ b/src/group.ts @@ -214,6 +214,54 @@ 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). + * + * 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; + while (current.children && current.children.length === 1) { + const only = current.children[0]; + collapsedLabels.push(only.label === "other" ? "unset" : only.label); + 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: current.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 From ad819caa5ab72866f970d82894ec485bd72df9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 22:42:21 +0200 Subject: [PATCH 2/2] Fix review: preserve fork headings and accumulate groups when collapsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on PR #185: - consolidateNode no longer merges a child into the collapsed suffix when that child itself forks into 2+ children — the fork point now stays its own heading instead of disappearing. - groups from every merged node (root + intermediates) are now accumulated instead of only keeping the deepest node's groups, since overlap-nested parents can own repos directly. --- src/group.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/group.ts | 15 +++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/group.test.ts b/src/group.test.ts index 0af8cad..b2b32f2 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -374,6 +374,45 @@ describe("consolidateTeamHierarchy", () => { 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-"]]); diff --git a/src/group.ts b/src/group.ts index 1fd823b..40de18e 100644 --- a/src/group.ts +++ b/src/group.ts @@ -222,7 +222,14 @@ function assignLevels(node: TeamSection, lvl: number): TeamSection { * 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). + * 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 @@ -238,9 +245,13 @@ export function consolidateTeamHierarchy(sections: TeamSection[]): TeamSection[] 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; } @@ -256,7 +267,7 @@ function consolidateNode(node: TeamSection): TeamSection { return { label, - groups: current.groups, + groups, level: node.level, ...(children ? { children } : {}), };