Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/group.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import {
applyTeamPick,
consolidateTeamHierarchy,
flattenTeamSections,
groupByTeamHierarchy,
groupByTeamPrefix,
Expand Down Expand Up @@ -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", () => {
Expand Down
59 changes: 59 additions & 0 deletions src/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading