From be72451d7a3c4f391a249ba3a2b72252c4f1c6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 22:26:28 +0200 Subject: [PATCH 1/2] Add groupByTeamHierarchy: tree-shaped multi-level team-prefix grouping --- src/group.test.ts | 137 ++++++++++++++++++++++++++++++ src/group.ts | 208 ++++++++++++++++++++++++++++++++++++++-------- src/types.ts | 17 +++- 3 files changed, 326 insertions(+), 36 deletions(-) diff --git a/src/group.test.ts b/src/group.test.ts index ea25f01..c98279d 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { applyTeamPick, flattenTeamSections, + groupByTeamHierarchy, groupByTeamPrefix, moveRepoToSection, rebuildTeamSections, @@ -145,6 +146,142 @@ describe("groupByTeamPrefix — multiple prefixes", () => { }); }); +// ─── groupByTeamHierarchy ───────────────────────────────────────────────────── + +/** Flattens a tree's labels (with indent per level) into a single array for + * easy assertions, depth-first, in the order sections are emitted. */ +function collectLabels(sections: TeamSection[]): string[] { + const out: string[] = []; + for (const s of sections) { + out.push(`${" ".repeat(s.level ?? 0)}${s.label}`); + if (s.children) out.push(...collectLabels(s.children)); + } + return out; +} + +describe("groupByTeamHierarchy — single-level chain (parity with groupByTeamPrefix)", () => { + it("behaves like groupByTeamPrefix for a single 1-level chain", () => { + const groups = [makeGroup("org/a", ["squad-frontend"]), makeGroup("org/b", ["squad-mobile"])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + const labels = sections.map((s) => s.label); + expect(labels).toContain("squad-frontend"); + expect(labels).toContain("squad-mobile"); + expect(sections.every((s) => (s.level ?? 0) === 0)).toBe(true); + }); + + it("returns empty array for no groups and no chains", () => { + expect(groupByTeamHierarchy([], [])).toEqual([]); + }); + + it("repos matching no chain at all go to a top-level 'other'", () => { + const groups = [makeGroup("org/a", ["squad-frontend"]), makeGroup("org/b", ["chapter-x"])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + const other = sections.find((s) => s.label === "other"); + expect(other).toBeDefined(); + expect(other!.level).toBe(0); + expect(other!.groups[0].repoFullName).toBe("org/b"); + }); +}); + +describe("groupByTeamHierarchy — 2-level chain", () => { + it("groups by the first prefix, then sub-groups each section by the second", () => { + const groups = [ + makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/b", ["gamme-client", "squad-billing"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("gamme-client"); + expect(sections[0].level).toBe(0); + expect(sections[0].groups).toEqual([]); // subdivided, not a leaf + const childLabels = (sections[0].children ?? []).map((c) => c.label).toSorted(); + expect(childLabels).toEqual(["squad-billing", "squad-dashboard"]); + for (const child of sections[0].children ?? []) { + expect(child.level).toBe(1); + } + }); + + it("repos with no match at the second level fall into a nested 'other'", () => { + const groups = [makeGroup("org/a", ["gamme-client"])]; // no squad- team + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const child = sections[0].children ?? []; + expect(child.map((c) => c.label)).toEqual(["other"]); + expect(child[0].level).toBe(1); + expect(child[0].groups[0].repoFullName).toBe("org/a"); + }); + + it("supports a 3-level chain recursively", () => { + const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard", "chapter-fe"])]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-", "chapter-"]]); + const l1 = sections[0]; + const l2 = l1.children![0]; + const l3 = l2.children![0]; + expect(l1.label).toBe("gamme-client"); + expect(l2.label).toBe("squad-dashboard"); + expect(l3.label).toBe("chapter-fe"); + expect([l1.level, l2.level, l3.level]).toEqual([0, 1, 2]); + expect(l3.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + }); +}); + +describe("groupByTeamHierarchy — multiple independent chains", () => { + it("processes each chain sequentially against the remaining pool", () => { + const groups = [ + makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/b", ["chapter-backend"]), + makeGroup("org/c", []), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const labels = sections.map((s) => s.label); + expect(labels).toEqual(["gamme-client", "chapter-backend", "other"]); + expect(sections[2].groups[0].repoFullName).toBe("org/c"); + }); +}); + +describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () => { + it("nests a longer team name under a shorter one that is its prefix", () => { + const groups = [ + makeGroup("org/a", ["gamme-lead-client"]), + makeGroup("org/b", ["gamme-lead-client-p1"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("gamme-lead-client"); + expect(sections[0].level).toBe(0); + expect(sections[0].children).toHaveLength(1); + expect(sections[0].children![0].label).toBe("gamme-lead-client-p1"); + expect(sections[0].children![0].level).toBe(1); + }); + + it("cascades nesting across 3 overlapping names", () => { + const groups = [ + makeGroup("org/a", ["gamme-lead-client"]), + makeGroup("org/b", ["gamme-lead-client-p1"]), + makeGroup("org/c", ["gamme-lead-client-p1-x"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + expect(collectLabels(sections)).toEqual([ + "gamme-lead-client", + " gamme-lead-client-p1", + " gamme-lead-client-p1-x", + ]); + }); + + it("does not nest unrelated single-team labels as siblings", () => { + const groups = [makeGroup("org/a", ["squad-front"]), makeGroup("org/b", ["squad-back"])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + expect(sections.every((s) => !s.children || s.children.length === 0)).toBe(true); + }); + + it("does not nest combined ('a + b') or 'other' sections", () => { + const groups = [makeGroup("org/a", ["squad-front", "squad-back"]), makeGroup("org/b", [])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + const combined = sections.find((s) => s.label.includes(" + ")); + expect(combined).toBeDefined(); + expect(combined!.children ?? []).toHaveLength(0); + }); +}); + // ─── flattenTeamSections ────────────────────────────────────────────────────── describe("flattenTeamSections", () => { diff --git a/src/group.ts b/src/group.ts index 37e7c6d..a45fc88 100644 --- a/src/group.ts +++ b/src/group.ts @@ -26,52 +26,190 @@ export function groupByTeamPrefix(groups: RepoGroup[], prefixes: string[]): Team const remaining = new Set(groups); for (const prefix of prefixes) { - // Repos that have at least one team starting with this prefix - const matchingGroups = [...remaining].filter((g) => - (g.teams ?? []).some((t) => t.startsWith(prefix)), - ); - if (matchingGroups.length === 0) continue; - - // Bucket by number of teams matching this prefix - const byCount = new Map(); - for (const g of matchingGroups) { - const matchingTeams = (g.teams ?? []).filter((t) => t.startsWith(prefix)); - const count = matchingTeams.length; - if (!byCount.has(count)) byCount.set(count, []); - byCount.get(count)!.push(g); - remaining.delete(g); - } + sections.push(...bucketSingleLevel(remaining, prefix)); + } - // Process buckets in ascending count order (1 team, then 2, then 3 …) - for (const count of [...byCount.keys()].toSorted((a, b) => a - b)) { - const groupsInBucket = byCount.get(count)!; - - // Within each count-bucket, group by the sorted team combination - const byCombo = new Map(); - for (const g of groupsInBucket) { - const matchingTeams = (g.teams ?? []) - .filter((t) => t.startsWith(prefix)) - .toSorted() - .join(" + "); - if (!byCombo.has(matchingTeams)) byCombo.set(matchingTeams, []); - byCombo.get(matchingTeams)!.push(g); - } + // Repos not matched by any prefix + if (remaining.size > 0) { + sections.push({ label: "other", groups: [...remaining] }); + } - // Stable ordering: emit combo sections in alphabetical order of the label - for (const label of [...byCombo.keys()].toSorted()) { - sections.push({ label, groups: byCombo.get(label)! }); - } + return sections; +} + +/** + * Buckets the repos in `remaining` that have at least one team starting with + * `prefix` into one `TeamSection` per matching-team combination, removing + * matched repos from `remaining` (mutated in place). Repos are first bucketed + * by the *number* of matching teams (1, then 2, then 3 …), then within each + * count-bucket by the sorted combination of matching team names. + * + * Shared by `groupByTeamPrefix` (flat, single level) and + * `groupByTeamHierarchy` (tree, applied at every depth of a prefix chain). + * Returns an empty array when nothing in `remaining` matches `prefix`. + */ +function bucketSingleLevel(remaining: Set, prefix: string): TeamSection[] { + const sections: TeamSection[] = []; + const matchingGroups = [...remaining].filter((g) => + (g.teams ?? []).some((t) => t.startsWith(prefix)), + ); + if (matchingGroups.length === 0) return sections; + + const byCount = new Map(); + for (const g of matchingGroups) { + const matchingTeams = (g.teams ?? []).filter((t) => t.startsWith(prefix)); + const count = matchingTeams.length; + if (!byCount.has(count)) byCount.set(count, []); + byCount.get(count)!.push(g); + remaining.delete(g); + } + + for (const count of [...byCount.keys()].toSorted((a, b) => a - b)) { + const groupsInBucket = byCount.get(count)!; + + const byCombo = new Map(); + for (const g of groupsInBucket) { + const matchingTeams = (g.teams ?? []) + .filter((t) => t.startsWith(prefix)) + .toSorted() + .join(" + "); + if (!byCombo.has(matchingTeams)) byCombo.set(matchingTeams, []); + byCombo.get(matchingTeams)!.push(g); + } + + for (const label of [...byCombo.keys()].toSorted()) { + sections.push({ label, groups: byCombo.get(label)! }); } } - // Repos not matched by any prefix + return sections; +} + +// ─── Hierarchical (nested) team-prefix grouping ─────────────────────────────── + +/** + * Groups `RepoGroup[]` into a *tree* of `TeamSection`s from one or more + * independent prefix chains. Each chain is an ordered list of prefixes, one + * per nesting depth: `["gamme-", "squad-"]` groups repos by teams matching + * `gamme-` first, then sub-groups each resulting section by teams matching + * `squad-`. Multiple chains are processed independently and sequentially + * (like `groupByTeamPrefix`'s multi-prefix list), each drawing from the pool + * of repos not yet claimed by an earlier chain. + * + * On top of the explicit chain depth, this also auto-nests sections whose + * single-team label is a prefix of another single-team label at the same + * depth (e.g. `gamme-lead-client` becomes the parent of + * `gamme-lead-client-p1`) instead of listing them as unrelated siblings. + * Combined-label sections (`"a + b"`) and `"other"` sections are never + * auto-nested. + * + * Repos matching no prefix at a given depth are collected into an `"other"` + * child at that depth; repos matching no chain at all are collected into a + * single top-level `"other"` section, mirroring `groupByTeamPrefix`. + * + * Pure function — no mutation of `groups` or its elements. + */ +export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): TeamSection[] { + const sections: TeamSection[] = []; + const remaining = new Set(groups); + + for (const chain of chains) { + if (chain.length === 0) continue; + + const siblings = bucketSingleLevel(remaining, chain[0]).map((s) => ({ ...s, level: 0 })); + if (siblings.length === 0) continue; + + const nested = nestOverlappingLabels(siblings, 0); + sections.push(...nested.map((s) => applyChainDepth(s, chain, 1))); + } + if (remaining.size > 0) { - sections.push({ label: "other", groups: [...remaining] }); + sections.push({ label: "other", groups: [...remaining], level: 0, children: [] }); } return sections; } +/** + * Recursively subdivides `node` by the next prefix in `chain` (at `depth`), + * descending first through any auto-nested overlap children (same `depth`, + * since auto-nesting does not consume an explicit chain level) before + * splitting an actual leaf's `groups`. No-op once `depth` exceeds the chain + * or the node has no repos left to split. + */ +function applyChainDepth(node: TeamSection, chain: string[], depth: number): TeamSection { + if (node.children && node.children.length > 0) { + return { ...node, children: node.children.map((c) => applyChainDepth(c, chain, depth)) }; + } + if (depth >= chain.length) return node; + + const level = (node.level ?? 0) + 1; + const localRemaining = new Set(node.groups); + const siblings = bucketSingleLevel(localRemaining, chain[depth]).map((s) => ({ ...s, level })); + if (localRemaining.size > 0) { + siblings.push({ label: "other", groups: [...localRemaining], level, children: [] }); + } + if (siblings.length === 0) return node; + + const nested = nestOverlappingLabels(siblings, level); + const children = nested.map((c) => applyChainDepth(c, chain, depth + 1)); + return { ...node, groups: [], children }; +} + +/** + * Nests sections whose single-team `label` is a proper prefix of another + * single-team label at the same `level` (e.g. `gamme-lead-client` becomes the + * parent of `gamme-lead-client-p1`), instead of leaving them as siblings. + * Combined-label (`"a + b"`) and `"other"` sections are left untouched at + * `level` and passed through unnested. When a chain of overlaps exists + * (A prefix of B prefix of C), nesting cascades and `level` is incremented + * once per hop from the shallowest ancestor. + */ +function nestOverlappingLabels(sections: TeamSection[], level: number): TeamSection[] { + const nestable = sections.filter((s) => s.label !== "other" && !s.label.includes(" + ")); + const rest = sections + .filter((s) => s.label === "other" || s.label.includes(" + ")) + .map((s) => ({ ...s, level, children: [] })); + + const nodeByLabel = new Map( + nestable.map((s) => [s.label, { ...s, level, children: [] }]), + ); + + const parentOf = new Map(); + for (const s of nestable) { + let bestParent: string | undefined; + for (const other of nestable) { + if (other.label === s.label) continue; + if ( + s.label.startsWith(other.label) && + (bestParent === undefined || other.label.length > bestParent.length) + ) { + bestParent = other.label; + } + } + if (bestParent !== undefined) parentOf.set(s.label, bestParent); + } + + for (const [child, parent] of parentOf) { + nodeByLabel.get(parent)!.children!.push(nodeByLabel.get(child)!); + } + + const roots = nestable + .filter((s) => !parentOf.has(s.label)) + .map((s) => assignLevels(nodeByLabel.get(s.label)!, level)); + + return [...roots, ...rest]; +} + +/** Sets `level` on `node` (and cascades +1 per depth into its children), mutating in place. */ +function assignLevels(node: TeamSection, lvl: number): TeamSection { + node.level = lvl; + if (node.children && node.children.length > 0) { + node.children = node.children.map((c) => assignLevels(c, lvl + 1)); + } + return node; +} + /** * Assigns all repos from the combined-label section (e.g. `"squad-frontend + squad-mobile"`) * to a single chosen team section. diff --git a/src/types.ts b/src/types.ts index ae5f692..d971f07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,10 +57,25 @@ export interface Row { sectionLabel?: string; } -/** One labelled group of repos produced by `groupByTeamPrefix`. */ +/** + * One labelled group of repos produced by `groupByTeamPrefix` or + * `groupByTeamHierarchy`. A node either owns repos directly (`groups`, a + * leaf) or is subdivided into `children` — the two are not expected to be + * populated at the same time for hierarchy-aware consumers, though flat + * consumers (`groupByTeamPrefix`) only ever set `groups`. + */ export interface TeamSection { label: string; groups: RepoGroup[]; + /** Nesting depth: 0 for a top-level section, 1 for a section nested one + * level down (via a chained `--group-by-team-prefix` level or an + * auto-detected overlapping team name), etc. Only set by + * `groupByTeamHierarchy`. */ + level?: number; + /** Present (non-empty) when this section was subdivided further, either by + * the next prefix in the chain or by an auto-detected overlapping + * team-name relationship. Only set by `groupByTeamHierarchy`. */ + children?: TeamSection[]; } export type OutputFormat = "markdown" | "json"; From 47377b13d5264053c379ba16c1de4bef108fd5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 23 Aug 2026 22:40:46 +0200 Subject: [PATCH 2/2] Fix review: split a section's own groups even when it has overlap children Addresses Copilot review on PR #184: - applyChainDepth no longer skips splitting a node's own groups by the next chain-level prefix just because it also has overlap-nested children (both can now coexist and each gets subdivided correctly). - TeamSection docs updated: a node can have non-empty groups AND children at the same time (was previously documented as either/or). - children is now omitted (not an empty array) on sections that were never subdivided, matching the documented invariant. --- src/group.test.ts | 41 +++++++++++++++++++++++++++++++++++ src/group.ts | 55 +++++++++++++++++++++++++++++++---------------- src/types.ts | 18 ++++++++++------ 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/group.test.ts b/src/group.test.ts index c98279d..f9bff00 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -280,6 +280,47 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = expect(combined).toBeDefined(); expect(combined!.children ?? []).toHaveLength(0); }); + + it("omits the children field entirely on leaf sections instead of an empty array", () => { + const groups = [makeGroup("org/a", ["squad-front"])]; + const sections = groupByTeamHierarchy(groups, [["squad-"]]); + expect(sections[0].children).toBeUndefined(); + }); + + it("keeps a parent's own groups when it also has an overlap-nested child", () => { + const groups = [ + makeGroup("org/a", ["gamme-lead-client"]), + makeGroup("org/b", ["gamme-lead-client-p1"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + expect(sections[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(sections[0].children).toHaveLength(1); + }); + + it("splits a parent's own groups by the next chain level even when it also has an overlap-nested child", () => { + const groups = [ + makeGroup("org/a", ["gamme-lead-client"]), + makeGroup("org/b", ["gamme-lead-client-p1", "squad-mobile"]), + makeGroup("org/c", ["gamme-lead-client", "squad-billing"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + expect(sections).toHaveLength(1); + const parent = sections[0]; + expect(parent.label).toBe("gamme-lead-client"); + // Fully subdivided — none of its own repos are left flat on the parent. + expect(parent.groups).toEqual([]); + const childLabels = (parent.children ?? []).map((c) => c.label).toSorted(); + expect(childLabels).toEqual(["gamme-lead-client-p1", "other", "squad-billing"]); + const squadBilling = parent.children!.find((c) => c.label === "squad-billing")!; + expect(squadBilling.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + const other = parent.children!.find((c) => c.label === "other")!; + expect(other.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + // The overlap-nested child was ALSO subdivided by the next chain level. + const p1 = parent.children!.find((c) => c.label === "gamme-lead-client-p1")!; + expect(p1.children).toHaveLength(1); + expect(p1.children![0].label).toBe("squad-mobile"); + expect(p1.children![0].groups.map((g) => g.repoFullName)).toEqual(["org/b"]); + }); }); // ─── flattenTeamSections ────────────────────────────────────────────────────── diff --git a/src/group.ts b/src/group.ts index a45fc88..8b8958e 100644 --- a/src/group.ts +++ b/src/group.ts @@ -124,36 +124,40 @@ export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): T } if (remaining.size > 0) { - sections.push({ label: "other", groups: [...remaining], level: 0, children: [] }); + sections.push({ label: "other", groups: [...remaining], level: 0 }); } - return sections; + return sections.map(pruneEmptyChildren); } /** - * Recursively subdivides `node` by the next prefix in `chain` (at `depth`), - * descending first through any auto-nested overlap children (same `depth`, - * since auto-nesting does not consume an explicit chain level) before - * splitting an actual leaf's `groups`. No-op once `depth` exceeds the chain - * or the node has no repos left to split. + * Recursively subdivides `node` by the next prefix in `chain` (at `depth`). + * Any pre-existing overlap-nested `children` haven't consumed `chain[depth]` + * yet either, so they're recursed into first (at the same `depth`); `node`'s + * own `groups` (repos owned directly by this section, which can coexist with + * overlap children — see `TeamSection`) are then split into *additional* + * children. No-op once `depth` exceeds the chain or there is nothing left to + * split at this node. */ function applyChainDepth(node: TeamSection, chain: string[], depth: number): TeamSection { - if (node.children && node.children.length > 0) { - return { ...node, children: node.children.map((c) => applyChainDepth(c, chain, depth)) }; + const recursedChildren = (node.children ?? []).map((c) => applyChainDepth(c, chain, depth)); + + if (depth >= chain.length || node.groups.length === 0) { + return recursedChildren.length > 0 ? { ...node, children: recursedChildren } : node; } - if (depth >= chain.length) return node; const level = (node.level ?? 0) + 1; const localRemaining = new Set(node.groups); const siblings = bucketSingleLevel(localRemaining, chain[depth]).map((s) => ({ ...s, level })); if (localRemaining.size > 0) { - siblings.push({ label: "other", groups: [...localRemaining], level, children: [] }); + siblings.push({ label: "other", groups: [...localRemaining], level }); } - if (siblings.length === 0) return node; - const nested = nestOverlappingLabels(siblings, level); - const children = nested.map((c) => applyChainDepth(c, chain, depth + 1)); - return { ...node, groups: [], children }; + const splitChildren = nestOverlappingLabels(siblings, level).map((c) => + applyChainDepth(c, chain, depth + 1), + ); + + return { ...node, groups: [], children: [...recursedChildren, ...splitChildren] }; } /** @@ -169,9 +173,9 @@ function nestOverlappingLabels(sections: TeamSection[], level: number): TeamSect const nestable = sections.filter((s) => s.label !== "other" && !s.label.includes(" + ")); const rest = sections .filter((s) => s.label === "other" || s.label.includes(" + ")) - .map((s) => ({ ...s, level, children: [] })); + .map((s) => ({ ...s, level })); - const nodeByLabel = new Map( + const nodeByLabel = new Map( nestable.map((s) => [s.label, { ...s, level, children: [] }]), ); @@ -191,7 +195,7 @@ function nestOverlappingLabels(sections: TeamSection[], level: number): TeamSect } for (const [child, parent] of parentOf) { - nodeByLabel.get(parent)!.children!.push(nodeByLabel.get(child)!); + nodeByLabel.get(parent)!.children.push(nodeByLabel.get(child)!); } const roots = nestable @@ -210,6 +214,21 @@ function assignLevels(node: TeamSection, lvl: number): TeamSection { return node; } +/** + * Recursively drops an empty `children` array so the field is only present + * when a section actually has nested sub-sections, matching `TeamSection`'s + * documented invariant. Pure — returns a new tree, does not mutate `node`. + */ +function pruneEmptyChildren(node: TeamSection): TeamSection { + if (!node.children) return node; + if (node.children.length === 0) { + const { children: _empty, ...rest } = node; + void _empty; + return rest as TeamSection; + } + return { ...node, children: node.children.map(pruneEmptyChildren) }; +} + /** * Assigns all repos from the combined-label section (e.g. `"squad-frontend + squad-mobile"`) * to a single chosen team section. diff --git a/src/types.ts b/src/types.ts index d971f07..491333e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,10 +59,12 @@ export interface Row { /** * One labelled group of repos produced by `groupByTeamPrefix` or - * `groupByTeamHierarchy`. A node either owns repos directly (`groups`, a - * leaf) or is subdivided into `children` — the two are not expected to be - * populated at the same time for hierarchy-aware consumers, though flat - * consumers (`groupByTeamPrefix`) only ever set `groups`. + * `groupByTeamHierarchy`. Flat consumers (`groupByTeamPrefix`) only ever set + * `groups`. Hierarchy nodes (`groupByTeamHierarchy`) can have `groups` (repos + * that belong to this section itself, with no more specific match), nested + * `children` (more specific sub-sections), or both at once — a team can + * directly own repos while some of its members also match a more specific + * overlapping team name or the next prefix in an explicit chain. */ export interface TeamSection { label: string; @@ -72,9 +74,11 @@ export interface TeamSection { * auto-detected overlapping team name), etc. Only set by * `groupByTeamHierarchy`. */ level?: number; - /** Present (non-empty) when this section was subdivided further, either by - * the next prefix in the chain or by an auto-detected overlapping - * team-name relationship. Only set by `groupByTeamHierarchy`. */ + /** Present (non-empty) only when this section has nested sub-sections — + * from the next prefix in an explicit chain, or an auto-detected + * overlapping team-name relationship. A node can have non-empty `groups` + * at the same time (see the type-level doc above). Only set by + * `groupByTeamHierarchy`. */ children?: TeamSection[]; }