From 0e7914b85649e3536497dc1ef47161027ae126a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 07:13:32 +0200 Subject: [PATCH 1/3] Add --exclude-team-prefixes: exclude noisy team-name prefixes from grouping Filters teams matching given prefixes out of consideration before groupByTeamHierarchy runs, reducing ambiguous combined sections at the source rather than trying to resolve them after the fact (e.g. many chapter-validators-* sub-teams). A repo left with no matching team after exclusion falls into 'other', same as today. Only applies with --group-by-team-prefix; threaded through the replay command. Closes #200 --- docs/reference/cli-options.md | 31 ++++++++++---------- docs/usage/team-grouping.md | 18 ++++++++++++ github-code-search.ts | 32 ++++++++++++++++++++ src/completions.test.ts | 12 ++++++++ src/completions.ts | 6 ++++ src/group.test.ts | 55 +++++++++++++++++++++++++++++++++++ src/group.ts | 24 +++++++++++++++ src/output.test.ts | 26 +++++++++++++++++ src/output.ts | 8 +++++ src/tui.ts | 2 ++ 10 files changed, 199 insertions(+), 15 deletions(-) diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index f59a32c..f2bde04 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -31,21 +31,22 @@ github-code-search completions [--shell ] ## Search options -| Option | Type | Required | Default | Description | -| ----------------------------------- | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | -| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | -| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | -| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | -| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | -| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | -| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | -| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | -| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | -| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | -| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | -| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | -| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | +| Option | Type | Required | Default | Description | +| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | +| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | +| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | +| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | +| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | +| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | +| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | +| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | +| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | +| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | +| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | +| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | +| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | +| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | ## Global options diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index dde817b..f1f04f2 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -221,6 +221,24 @@ github-code-search query "useFeatureFlag" --org fulll \ - An explicit `--pick-team` for the same section always wins: run explicit picks first, then `--pick-team-auto` resolves whatever combined sections remain. - The replay command emits `--pick-team-auto` when it was used, so a session is reproduced exactly. +## Excluding noisy team prefixes + +Some orgs have many closely related, deeply-overlapping team names under one prefix (e.g. `chapter-validators-core`, `chapter-validators-client`, `chapter-validators-frontend-client`, ...). When several of these co-occur on the same repos, `--group-by-team-prefix` produces many distinct combined sections that neither `--pick-team-auto` nor manual `--pick-team` can cleanly resolve, since no single team name is a common prefix of the others. + +`--exclude-team-prefixes` removes matching teams from consideration **before** grouping runs, reducing ambiguous combos at the source: + +```bash +github-code-search query "useFeatureFlag" --org fulll \ + --group-by-team-prefix chapter- \ + --exclude-team-prefixes chapter-validators- \ + --pick-team-auto +``` + +- Comma-separated, same syntax as `--exclude-repositories` / `--exclude-extracts`. +- A repo left with **no matching team** after exclusion falls into `other`, exactly like a repo with no matching team today. +- Only applies with `--group-by-team-prefix`; a warning is emitted (and the flag is a no-op) otherwise. +- The replay command emits `--exclude-team-prefixes` when it was used, so a session is reproduced exactly. + ## Re-pick & undo pick After using `--pick-team` (or the interactive `p` shortcut) to assign a combined section to a team, individual repos marked `◈` can be re-assigned or restored to their original combined section at any time — regardless of how deeply nested the original section was. diff --git a/github-code-search.ts b/github-code-search.ts index 6306bf0..ad51567 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -23,6 +23,7 @@ import { buildOutput } from "./src/output.ts"; import { applyTeamPickInTree, autoPickTeamsByCommonPrefix, + excludeTeamsByPrefix, findCombinedSectionPaths, flattenTeamHierarchy, groupByTeamHierarchy, @@ -190,6 +191,19 @@ function addSearchOptions(cmd: Command): Command { ].join("\n"), "", ) + .option( + "--exclude-team-prefixes ", + [ + "Comma-separated team-name prefixes to exclude from grouping entirely,", + "before combined sections are formed. Useful to filter out noisy/overly", + "granular team names (e.g. many chapter-validators-* sub-teams) that", + "would otherwise produce combined sections --pick-team-auto can't resolve.", + "A repo left with no matching team after exclusion falls into 'other'.", + "Only applies with --group-by-team-prefix.", + "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#excluding-noisy-team-prefixes", + ].join("\n"), + "", + ) .option( "--pick-team ", [ @@ -246,6 +260,7 @@ async function searchAction( includeArchived: boolean; excludeTemplateRepositories: boolean; groupByTeamPrefix: string; + excludeTeamPrefixes?: string; pickTeam: string[]; pickTeamAuto?: boolean; cache: boolean; @@ -377,6 +392,11 @@ async function searchAction( ); } } + if (!opts.groupByTeamPrefix && opts.excludeTeamPrefixes) { + process.stderr.write( + "warning: --exclude-team-prefixes requires --group-by-team-prefix; skipping\n", + ); + } if (opts.groupByTeamPrefix) { const { chains, warnings: chainWarnings } = parseTeamPrefixChains(opts.groupByTeamPrefix); for (const w of chainWarnings) process.stderr.write(`warning: ${w}\n`); @@ -395,6 +415,16 @@ async function searchAction( g.teams = teamMap.get(g.repoFullName) ?? []; } + const excludeTeamPrefixes = opts.excludeTeamPrefixes + ? opts.excludeTeamPrefixes + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + : []; + if (excludeTeamPrefixes.length > 0) { + groups = excludeTeamsByPrefix(groups, excludeTeamPrefixes); + } + let sections = groupByTeamHierarchy(groups, chains); for (const assignment of opts.pickTeam) { @@ -436,6 +466,7 @@ async function searchAction( includeArchived, excludeTemplates, groupByTeamPrefix: opts.groupByTeamPrefix, + excludeTeamPrefixes: opts.excludeTeamPrefixes, pickTeamAuto: opts.pickTeamAuto, regexHint: opts.regexHint, pickTeams: Object.keys(pickTeams).length > 0 ? pickTeams : undefined, @@ -497,6 +528,7 @@ async function searchAction( includeArchived, excludeTemplates, opts.groupByTeamPrefix, + opts.excludeTeamPrefixes ?? "", Boolean(opts.pickTeamAuto), opts.regexHint ?? "", Object.keys(pickTeams).length > 0 ? pickTeams : {}, diff --git a/src/completions.test.ts b/src/completions.test.ts index 60cbba0..033a09b 100644 --- a/src/completions.test.ts +++ b/src/completions.test.ts @@ -35,6 +35,10 @@ describe("generateCompletion", () => { expect(generateCompletion("bash")).toContain("--pick-team-auto"); }); + it("contains --exclude-team-prefixes", () => { + expect(generateCompletion("bash")).toContain("--exclude-team-prefixes"); + }); + it("contains format values (markdown, json)", () => { const script = generateCompletion("bash"); expect(script).toContain("markdown"); @@ -83,6 +87,10 @@ describe("generateCompletion", () => { expect(generateCompletion("zsh")).toContain("--pick-team-auto"); }); + it("contains --exclude-team-prefixes", () => { + expect(generateCompletion("zsh")).toContain("--exclude-team-prefixes"); + }); + it("contains a 'compdef' directive (zsh-style)", () => { const script = generateCompletion("zsh"); expect(script).toContain("compdef "); @@ -119,6 +127,10 @@ describe("generateCompletion", () => { expect(generateCompletion("fish")).toContain("pick-team-auto"); }); + it("contains exclude-team-prefixes", () => { + expect(generateCompletion("fish")).toContain("exclude-team-prefixes"); + }); + it("uses fish 'complete -c' syntax", () => { const script = generateCompletion("fish"); expect(script).toContain("complete -c github-code-search"); diff --git a/src/completions.ts b/src/completions.ts index b30b701..aa9bc7f 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -45,6 +45,12 @@ const OPTIONS = [ takesArg: true, values: [], }, + { + flag: "exclude-team-prefixes", + description: "Comma-separated team-name prefixes to exclude from grouping", + takesArg: true, + values: [], + }, { flag: "pick-team", description: "Assign a combined team section to a single owner (repeatable)", diff --git a/src/group.test.ts b/src/group.test.ts index 0608f2a..137cb5c 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -3,6 +3,7 @@ import { applyTeamPick, applyTeamPickInTree, autoPickTeamsByCommonPrefix, + excludeTeamsByPrefix, findCombinedSectionPaths, flattenTeamHierarchy, flattenTeamSections, @@ -34,6 +35,60 @@ function makeGroup(repo: string, teams: string[] = []): RepoGroup { }; } +// ─── excludeTeamsByPrefix ───────────────────────────────────────────────────── + +describe("excludeTeamsByPrefix", () => { + it("removes teams matching an excluded prefix, keeps the rest", () => { + const groups = [makeGroup("org/a", ["chapter-secops", "chapter-validators-core"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("supports multiple exclude prefixes", () => { + const groups = [ + makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-aix"]), + ]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-", "chapter-architect-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("leaves teams unchanged when no team matches any exclude prefix", () => { + const groups = [makeGroup("org/a", ["chapter-secops"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("returns a repo with an empty teams array when every team is excluded", () => { + const groups = [makeGroup("org/a", ["chapter-validators-core", "chapter-validators-client"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual([]); + }); + + it("is a no-op (same reference) when excludePrefixes is empty", () => { + const groups = [makeGroup("org/a", ["chapter-secops"])]; + expect(excludeTeamsByPrefix(groups, [])).toBe(groups); + }); + + it("does not mutate the input groups or their teams array", () => { + const groups = [makeGroup("org/a", ["chapter-secops", "chapter-validators-core"])]; + const before = JSON.stringify(groups); + excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(JSON.stringify(groups)).toBe(before); + }); + + it("reduces a combined section to a single-team section once ambiguity is removed", () => { + const groups = [ + makeGroup("org/a", ["chapter-secops", "chapter-validators-core"]), + makeGroup("org/b", ["chapter-secops"]), + ]; + const filtered = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + const sections = groupByTeamPrefix(filtered, ["chapter-"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("chapter-secops"); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); + }); +}); + // ─── groupByTeamPrefix ──────────────────────────────────────────────────────── describe("groupByTeamPrefix — basic single prefix", () => { diff --git a/src/group.ts b/src/group.ts index 5b34017..8d4db23 100644 --- a/src/group.ts +++ b/src/group.ts @@ -2,6 +2,30 @@ import type { RepoGroup, TeamSection } from "./types.ts"; // ─── Team-prefix grouping ───────────────────────────────────────────────────── +/** + * Removes any team whose name starts with one of `excludePrefixes` from every + * repo's `teams` list, before grouping runs. Lets noisy/overly granular team + * prefixes (e.g. many `chapter-validators-*` sub-teams) be excluded from + * consideration entirely, reducing ambiguous combined sections at the source + * rather than trying to resolve them after the fact. + * + * Matching is case-sensitive `startsWith`, same as `bucketSingleLevel`'s + * `--group-by-team-prefix` matching, for consistency. A repo left with no + * matching teams behaves exactly like a repo with no matching team today + * (falls into `"other"` once grouped). + * + * Pure — returns new `RepoGroup` objects; does not mutate `groups` or its + * elements. No-op (repos returned unchanged, but still copied) when + * `excludePrefixes` is empty. + */ +export function excludeTeamsByPrefix(groups: RepoGroup[], excludePrefixes: string[]): RepoGroup[] { + if (excludePrefixes.length === 0) return groups; + return groups.map((g) => ({ + ...g, + teams: (g.teams ?? []).filter((t) => !excludePrefixes.some((p) => t.startsWith(p))), + })); +} + /** * Groups `RepoGroup[]` by GitHub team prefix(es). * diff --git a/src/output.test.ts b/src/output.test.ts index 4c1b5a3..ad1c07b 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -198,6 +198,22 @@ describe("buildReplayCommand", () => { expect(cmd).not.toContain("--group-by-team-prefix"); }); + it("includes --exclude-team-prefixes when set", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const opts: ReplayOptions = { + groupByTeamPrefix: "chapter-", + excludeTeamPrefixes: "chapter-validators-", + }; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).toContain("--exclude-team-prefixes 'chapter-validators-'"); + }); + + it("does not include --exclude-team-prefixes when unset (default)", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); + expect(cmd).not.toContain("--exclude-team-prefixes"); + }); + it("includes --pick-team-auto when pickTeamAuto is true", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const opts: ReplayOptions = { groupByTeamPrefix: "gamme-/squad-", pickTeamAuto: true }; @@ -867,6 +883,16 @@ describe("buildOutput", () => { expect(parsed.replayCommand).toContain("--pick-team-auto"); }); + it("threads excludeTeamPrefixes into the replay command", () => { + const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; + const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { + groupByTeamPrefix: "chapter-", + excludeTeamPrefixes: "chapter-validators-", + }); + const parsed = JSON.parse(out); + expect(parsed.replayCommand).toContain("--exclude-team-prefixes 'chapter-validators-'"); + }); + it("threads --group-by-team-prefix into json replay command", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { diff --git a/src/output.ts b/src/output.ts index 6e46be3..a80f50a 100644 --- a/src/output.ts +++ b/src/output.ts @@ -35,6 +35,9 @@ export interface ReplayOptions { includeArchived?: boolean; excludeTemplates?: boolean; groupByTeamPrefix?: string; + /** Mirrors `--exclude-team-prefixes` — excludes matching teams from + * consideration before grouping runs (see `excludeTeamsByPrefix`). */ + excludeTeamPrefixes?: string; /** Mirrors `--pick-team-auto` — auto-resolves combined sections by common * prefix (see `autoPickTeamsByCommonPrefix`). */ pickTeamAuto?: boolean; @@ -62,6 +65,7 @@ export function buildReplayCommand( includeArchived, excludeTemplates, groupByTeamPrefix, + excludeTeamPrefixes, pickTeamAuto, regexHint, pickTeams, @@ -114,6 +118,9 @@ export function buildReplayCommand( if (groupByTeamPrefix) { parts.push(`--group-by-team-prefix ${shellQuote(groupByTeamPrefix)}`); } + if (excludeTeamPrefixes) { + parts.push(`--exclude-team-prefixes ${shellQuote(excludeTeamPrefixes)}`); + } if (pickTeamAuto) { parts.push("--pick-team-auto"); } @@ -400,6 +407,7 @@ export function buildOutput( | "includeArchived" | "excludeTemplates" | "groupByTeamPrefix" + | "excludeTeamPrefixes" | "pickTeamAuto" | "regexHint" | "pickTeams" diff --git a/src/tui.ts b/src/tui.ts index 58269ca..cbfd389 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -143,6 +143,7 @@ export async function runInteractive( includeArchived = false, excludeTemplates = false, groupByTeamPrefix = "", + excludeTeamPrefixes = "", pickTeamAuto = false, regexHint = "", initialPickTeams: Record = {}, @@ -702,6 +703,7 @@ export async function runInteractive( includeArchived, excludeTemplates, groupByTeamPrefix, + excludeTeamPrefixes, pickTeamAuto, regexHint: regexHint || undefined, pickTeams: Object.keys(confirmedPicks).length > 0 ? confirmedPicks : undefined, From afef6139af6fab9a27352d732ba71d30b1c2ae9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 08:24:18 +0200 Subject: [PATCH 2/3] Use generic tribe-/squad- example names instead of org-specific ones Replaces gamme- (Fulll-specific French term) with tribe- (Spotify model terminology) across docs, CLI help text, and tests, and genericizes explicit product/business squad names (squad-dashboard, squad-billing, squad-demat, squad-accounting, squad-bank) to squad-a/b/c/d. chapter- and its specialty names (chapter-backend, chapter-architect, etc.) are kept as-is since they already read as generic engineering domains, not organization-specific identifiers. --- docs/reference/cli-options.md | 32 +-- docs/usage/team-grouping.md | 40 ++-- github-code-search.ts | 12 +- src/group.test.ts | 394 ++++++++++++++++------------------ src/group.ts | 24 +-- src/output.test.ts | 42 ++-- src/render.test.ts | 44 ++-- 7 files changed, 284 insertions(+), 304 deletions(-) diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index f2bde04..ad49d29 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -31,22 +31,22 @@ github-code-search completions [--shell ] ## Search options -| Option | Type | Required | Default | Description | -| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | -| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | -| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | -| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | -| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | -| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | -| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | -| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | -| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | -| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | -| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | -| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | -| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | -| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | +| Option | Type | Required | Default | Description | +| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | +| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | +| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | +| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | +| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | +| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | +| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | +| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | +| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`tribe-/squad-`), `,` separates independent chains (`tribe-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | +| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | +| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | +| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `tribe-a + tribe-a-p1` → `tribe-a`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | +| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | +| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | ## Global options diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index f1f04f2..bc0a693 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -25,18 +25,18 @@ The value of `--group-by-team-prefix` is a small grammar: - `,` separates **independent chains** — each is grouped on its own, in order, against whatever repos the previous chains haven't already claimed. ```bash -# One 2-level chain: group by gamme- first, then by squad- within each gamme +# One 2-level chain: group by tribe- first, then by squad- within each gamme github-code-search "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme-/squad- + --group-by-team-prefix tribe-/squad- ``` ```bash -# A 2-level chain (gamme-/squad-) plus an independent 1-level chain (chapter-) +# A 2-level chain (tribe-/squad-) plus an independent 1-level chain (chapter-) github-code-search "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme-/squad-,chapter- + --group-by-team-prefix tribe-/squad-,chapter- ``` -A chain can have as many levels as you need (`gamme-/squad-/chapter-`, …). Malformed segments (a stray leading/trailing/double `,` or `/`) are dropped with a warning on stderr rather than silently producing an empty prefix. +A chain can have as many levels as you need (`tribe-/squad-/chapter-`, …). Malformed segments (a stray leading/trailing/double `,` or `/`) are dropped with a warning on stderr rather than silently producing an empty prefix. ## Grouping algorithm @@ -53,11 +53,11 @@ Independent chains (separated by `,`) are processed in order, each consuming rep ### Automatic nesting of overlapping team names -Within one level, if a team's name is a **prefix of another team's name** (e.g. `gamme-lead-client` and `gamme-lead-client-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: +Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: ```text -## gamme-lead-client -### gamme-lead-client-p1 +## tribe-a +### tribe-a-p1 ``` This cascades across any number of overlapping names, and applies independently at every depth of a chain. @@ -90,20 +90,20 @@ This cascades across any number of overlapping names, and applies independently - [ ] [src/legacy.js:5:1](https://github.com/fulll/legacy-monolith/blob/main/src/legacy.js#L5) ``` -### Nested (`gamme-/squad-`) output +### Nested (`tribe-/squad-`) output Nested levels render as consecutive markdown headings (`##`, `###`, `####`, …, capped at H6) — a sibling section that shares an ancestor with the previous one doesn't repeat that ancestor's heading: ```text 7 repos · 7 files · 8 matches selected -## gamme-lead-client -### squad-bank +## tribe-a +### squad-a - **fulll/bank** (1 match) - [ ] [src/index.ts:3:14](https://github.com/fulll/bank/blob/main/src/index.ts#L3) -## gamme-lead-mobile +## tribe-b ### squad-core + squad-mobile - **fulll/tools-mobile** (1 match) @@ -129,7 +129,7 @@ Each result carries its full hierarchy path (root first) in a `section` array: "results": [ { "repo": "fulll/tools-mobile", - "section": ["gamme-lead-mobile", "squad-core + squad-mobile"], + "section": ["tribe-b", "squad-core + squad-mobile"], "matches": [{ "path": "src/index.ts", "url": "...", "line": 1, "col": 1 }] } ] @@ -141,10 +141,10 @@ Each result carries its full hierarchy path (root first) in a `section` array: In the TUI, team sections appear as separator lines between repository rows, indented by 2 spaces per nesting level: ```text -── gamme-lead-client - ── squad-bank +── tribe-a + ── squad-a ▶ ◉ fulll/bank (1 match) -── gamme-lead-mobile +── tribe-b ── squad-core + squad-mobile ▶ ◉ fulll/tools-mobile (1 match) ── other @@ -193,7 +193,7 @@ The combined label can be: - **A fully-qualified path**, joined with `>`, when the label is ambiguous or you'd rather be explicit: ```bash - --pick-team "gamme-lead-client > squad-a + squad-b"=squad-a + --pick-team "tribe-a > squad-a + squad-b"=squad-a ``` The flag is repeatable — add one `--pick-team` per combined section to resolve. The replay command emits `--pick-team` automatically (with a fully-qualified path when the pick was made on a nested section) when a pick was confirmed in the TUI. @@ -204,16 +204,16 @@ If the combined label or path is not found (typo, ambiguous, or the section was ## Auto-pick by common prefix -Many combined sections aren't actually ambiguous: when one of the team names is a literal prefix of every other team name in the combo (e.g. `gamme-lead-client` and `gamme-lead-client-p1`), the "parent" team is the obvious owner. `--pick-team-auto` resolves these automatically, without needing a manual `--pick-team`: +Many combined sections aren't actually ambiguous: when one of the team names is a literal prefix of every other team name in the combo (e.g. `tribe-a` and `tribe-a-p1`), the "parent" team is the obvious owner. `--pick-team-auto` resolves these automatically, without needing a manual `--pick-team`: ```bash github-code-search query "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme- \ + --group-by-team-prefix tribe- \ --pick-team-auto ``` ```text -## gamme-lead-client + gamme-lead-client-p1 → ## gamme-lead-client +## tribe-a + tribe-a-p1 → ## tribe-a ``` - Combos with **no common-prefix team** (e.g. `squad-frontend + squad-mobile` — neither is a prefix of the other) are left combined and unresolved, exactly like today. diff --git a/github-code-search.ts b/github-code-search.ts index ad51567..163bfef 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -79,7 +79,7 @@ function colorDesc(s: string): string { // would make Commander's Help.preformatted() (newline followed by // whitespace) treat the WHOLE description as already manually indented // and skip aligning continuation lines to the option column. - if (/^\s*(e\.g\.|repoA|myorg\/|squad-|chapter-|gamme-)/.test(line)) return style.dim(line); + if (/^\s*(e\.g\.|repoA|myorg\/|squad-|chapter-|tribe-)/.test(line)) return style.dim(line); // Colorize any remaining bare URL (http/https) anywhere in the line return line.replace(/(https?:\/\/\S+)/g, (url) => style.style(["cyan", "underline"], url)); }) @@ -181,9 +181,9 @@ function addSearchOptions(cmd: Command): Command { "--group-by-team-prefix ", [ "Comma-separated team-name prefixes used to group result repos by GitHub team.", - "Use / within one entry to nest levels: gamme-/squad- groups by gamme- first,", + "Use / within one entry to nest levels: tribe-/squad- groups by tribe- first,", "then sub-groups each section by squad-. Combine independent chains with ,:", - "gamme-/squad-,chapter-", + "tribe-/squad-,chapter-", "Repos are first grouped by single-team match, then multi-team, then the next", "level. Repos matching no prefix go into 'other'. Team names that overlap", "(e.g. squad-a and squad-a-legacy) are nested automatically.", @@ -211,7 +211,7 @@ function addSearchOptions(cmd: Command): Command { 'Format: "combined label"=chosenTeam (the = separator is required).', 'Example: --pick-team "squad-frontend + squad-mobile"=squad-frontend', "The combined label may be unqualified (auto-resolved when unambiguous)", - 'or a full path when nested / ambiguous: "gamme-client > squad-a + squad-b"=squad-a', + 'or a full path when nested / ambiguous: "tribe-a > squad-a + squad-b"=squad-a', "Repeatable — one flag per combined section to resolve.", "Only applies with --group-by-team-prefix.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#team-pick-mode", @@ -223,8 +223,8 @@ function addSearchOptions(cmd: Command): Command { "--pick-team-auto", [ "Auto-resolve combined team sections whose team names share a common", - 'prefix (e.g. "gamme-lead-client + gamme-lead-client-p1" \u2192 auto-picks', - '"gamme-lead-client"), without needing an explicit --pick-team.', + 'prefix (e.g. "tribe-a + tribe-a-p1" \u2192 auto-picks', + '"tribe-a"), without needing an explicit --pick-team.', "Combos with no common-prefix team (e.g. squad-a + squad-b) are left", "unresolved. An explicit --pick-team for the same section always wins.", "Applies at every hierarchy depth. Only applies with --group-by-team-prefix.", diff --git a/src/group.test.ts b/src/group.test.ts index 137cb5c..ddf2f21 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -251,24 +251,24 @@ describe("groupByTeamHierarchy — single-level chain (parity with groupByTeamPr 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"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("gamme-client"); + expect(sections[0].label).toBe("tribe-a"); 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"]); + expect(childLabels).toEqual(["squad-a", "squad-b"]); 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 groups = [makeGroup("org/a", ["tribe-a"])]; // no squad- team + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const child = sections[0].children ?? []; expect(child.map((c) => c.label)).toEqual(["other"]); expect(child[0].level).toBe(1); @@ -276,13 +276,13 @@ describe("groupByTeamHierarchy — 2-level chain", () => { }); 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 groups = [makeGroup("org/a", ["tribe-a", "squad-a", "chapter-fe"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-", "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(l1.label).toBe("tribe-a"); + expect(l2.label).toBe("squad-a"); 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"]); @@ -292,36 +292,36 @@ describe("groupByTeamHierarchy — 2-level chain", () => { 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/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), makeGroup("org/c", []), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const labels = sections.map((s) => s.label); - expect(labels).toEqual(["gamme-client", "chapter-backend", "other"]); + expect(labels).toEqual(["tribe-a", "chapter-backend", "other"]); expect(sections[2].groups[0].repoFullName).toBe("org/c"); }); - it("investigation (#issue: gamme-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (gamme-) match is invisible to that chain and falls through to a later chain", () => { - // Reported behaviour: `fulll/demat-workers` (team squad-demat, no gamme- + it("investigation (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (tribe-) match is invisible to that chain and falls through to a later chain", () => { + // Reported behaviour: `fulll/demat-workers` (team squad-c, no tribe- // team) ends up under a `chapter-` combined section instead of under - // `gamme-/squad-` as its `--group-by-team-prefix gamme-/squad-,chapter-` + // `tribe-/squad-` as its `--group-by-team-prefix tribe-/squad-,chapter-` // position would suggest. Root cause: a chain only claims a repo if it - // matches chain[0] (gamme-) FIRST — squad- is only ever applied to repos - // already captured under a gamme- section. A repo with a squad- team but - // no gamme- team is entirely invisible to the gamme-/squad- chain and + // matches chain[0] (tribe-) FIRST — squad- is only ever applied to repos + // already captured under a tribe- section. A repo with a squad- team but + // no tribe- team is entirely invisible to the tribe-/squad- chain and // falls through to the next chain (or "other") instead — chain // precedence, as currently implemented, is NOT "any prefix anywhere in // the chain", only "chain[0], in order". const groups = [ makeGroup("org/demat-workers", [ - "squad-demat", + "squad-c", "chapter-validators-client", "chapter-validators-core", ]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); - expect(sections.map((s) => s.label)).not.toContain("gamme-"); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); + expect(sections.map((s) => s.label)).not.toContain("tribe-"); const chapterCombo = sections.find((s) => s.label.includes("chapter-validators")); expect(chapterCombo).toBeDefined(); expect(chapterCombo!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); @@ -330,31 +330,24 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { 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-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("gamme-lead-client"); + expect(sections[0].label).toBe("tribe-a"); 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].label).toBe("tribe-a-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"]), + makeGroup("org/a", ["tribe-a"]), + makeGroup("org/b", ["tribe-a-p1"]), + makeGroup("org/c", ["tribe-a-p1-x"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); - expect(collectLabels(sections)).toEqual([ - "gamme-lead-client", - " gamme-lead-client-p1", - " gamme-lead-client-p1-x", - ]); + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); + expect(collectLabels(sections)).toEqual(["tribe-a", " tribe-a-p1", " tribe-a-p1-x"]); }); it("does not nest unrelated single-team labels as siblings", () => { @@ -378,57 +371,54 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = }); 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-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); 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"]), + makeGroup("org/a", ["tribe-a"]), + makeGroup("org/b", ["tribe-a-p1", "squad-mobile"]), + makeGroup("org/c", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); const parent = sections[0]; - expect(parent.label).toBe("gamme-lead-client"); + expect(parent.label).toBe("tribe-a"); // 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(childLabels).toEqual(["other", "squad-b", "tribe-a-p1"]); + const squadBilling = parent.children!.find((c) => c.label === "squad-b")!; 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")!; + const p1 = parent.children!.find((c) => c.label === "tribe-a-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"]); }); - it("investigation (#issue: gamme-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { - // Reported behaviour: `--group-by-team-prefix gamme-/squad-` (a 2-level - // chain) renders 3 heading levels — `gamme-lead-client` / - // `gamme-lead-client-security-p1` / `squad-accounting` — instead of the + it("investigation (#issue: tribe-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { + // Reported behaviour: `--group-by-team-prefix tribe-/squad-` (a 2-level + // chain) renders 3 heading levels — `tribe-a` / + // `tribe-a-security-p1` / `squad-d` — instead of the // 2 the chain declares. This documents that this is the INTENDED result // of combining two separate, both-documented mechanisms: automatic // overlap-nesting of single-team labels (this describe block) PLUS the // chain's own `/squad-` depth — not a bug in either mechanism alone. const groups = [ - makeGroup("org/parent-only", ["gamme-lead-client"]), - makeGroup("org/leaf", ["gamme-lead-client-security-p1", "squad-accounting"]), + makeGroup("org/parent-only", ["tribe-a"]), + makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(collectLabels(tree)).toEqual([ - "gamme-lead-client", - " gamme-lead-client-security-p1", - " squad-accounting", + "tribe-a", + " tribe-a-security-p1", + " squad-d", " other", ]); }); @@ -439,62 +429,59 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = describe("flattenTeamHierarchy", () => { it("tags the first repo of a 2-level leaf with both ancestor headings", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-a"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const flat = flattenTeamHierarchy(sections); expect(flat).toHaveLength(2); expect(flat[0].sectionPath).toEqual([ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ]); expect(flat[1].sectionPath).toBeUndefined(); }); it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const flat = flattenTeamHierarchy(sections); - // First leaf (alphabetically squad-billing comes first) gets both headings + // First leaf (alphabetically squad-a comes first) gets both headings expect(flat[0].sectionPath).toEqual([ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ]); - // Second leaf shares the "gamme-client" ancestor — only the new heading is listed - expect(flat[1].sectionPath).toEqual([{ label: "squad-dashboard", level: 1 }]); + // Second leaf shares the "tribe-a" ancestor — only the new heading is listed + expect(flat[1].sectionPath).toEqual([{ label: "squad-b", level: 1 }]); }); it("emits a full new path when moving to an unrelated top-level chain", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const flat = flattenTeamHierarchy(sections); expect(flat[1].sectionPath).toEqual([{ label: "chapter-backend", level: 0 }]); }); it("includes a parent's own repos even when it also has nested overlap children", () => { - // "gamme-lead-client" owns org/a directly AND has an overlap-nested - // child "gamme-lead-client-p1" owning org/b — both must appear. - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + // "tribe-a" owns org/a directly AND has an overlap-nested + // child "tribe-a-p1" owning org/b — both must appear. + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); const flat = flattenTeamHierarchy(sections); expect(flat.map((g) => g.repoFullName)).toEqual(["org/a", "org/b"]); - expect(flat[0].sectionPath).toEqual([{ label: "gamme-lead-client", level: 0 }]); - expect(flat[1].sectionPath).toEqual([{ label: "gamme-lead-client-p1", level: 1 }]); + expect(flat[0].sectionPath).toEqual([{ label: "tribe-a", level: 0 }]); + expect(flat[1].sectionPath).toEqual([{ label: "tribe-a-p1", level: 1 }]); }); it("does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard"])]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const before = JSON.stringify(sections); flattenTeamHierarchy(sections); expect(JSON.stringify(sections)).toBe(before); @@ -510,31 +497,28 @@ describe("flattenTeamHierarchy", () => { describe("rebuildTeamHierarchy", () => { it("round-trips a 2-level tree through flattenTeamHierarchy", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const original = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const original = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); it("round-trips a tree where a node has both own groups and children (overlap parent)", () => { - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const original = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const original = groupByTeamHierarchy(groups, [["tribe-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); it("round-trips multiple independent top-level chains", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), makeGroup("org/c", []), ]; - const original = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const original = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); @@ -566,12 +550,12 @@ describe("applyTeamPickInTree", () => { it("reassigns a nested combined section to a sibling at the same depth", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const gamme = updated.find((s) => s.label === "tribe-a")!; const childLabels = (gamme.children ?? []).map((c) => c.label); expect(childLabels).not.toContain("squad-a + squad-b"); const squadA = gamme.children!.find((c) => c.label === "squad-a")!; @@ -579,54 +563,54 @@ describe("applyTeamPickInTree", () => { }); it("tags moved repos with pickedFrom = joined path", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const gamme = updated.find((s) => s.label === "tribe-a")!; const squadA = gamme.children!.find((c) => c.label === "squad-a")!; - expect(squadA.groups[0].pickedFrom).toBe("gamme-client > squad-a + squad-b"); + expect(squadA.groups[0].pickedFrom).toBe("tribe-a > squad-a + squad-b"); }); it("creates a new sibling section when the chosen team has none yet", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-b"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-b"); + const gamme = updated.find((s) => s.label === "tribe-a")!; expect(gamme.children!.map((c) => c.label)).toContain("squad-b"); }); it("is a no-op when a path segment is not found", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = applyTeamPickInTree(tree, ["nope", "squad-a + squad-b"], "squad-a"); expect(result).toEqual(tree); }); it("preserves the picked section's own children (does not drop the subtree)", () => { - // Regression: a top-level combined section ("gamme-a + gamme-a-security-p1") + // Regression: a top-level combined section ("tribe-a + tribe-a-security-p1") // that was already subdivided by the next chain level (squad-) must keep // its nested children when picked — only its own (now empty) `groups` // were carried over before the fix, silently dropping every repo nested // underneath. const groups = [ makeGroup("org/tools-mobile", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", + "tribe-b", + "tribe-b-security-p1", "squad-core", "squad-mobile", ]), - makeGroup("org/wizard-mobile", ["gamme-lead-mobile", "gamme-lead-mobile-security-p1"]), + makeGroup("org/wizard-mobile", ["tribe-b", "tribe-b-security-p1"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - expect(combined.label).toBe("gamme-lead-mobile + gamme-lead-mobile-security-p1"); + expect(combined.label).toBe("tribe-b + tribe-b-security-p1"); expect(combined.groups).toEqual([]); // fully subdivided by squad- before the pick expect(combined.children).toHaveLength(2); // "squad-core + squad-mobile" and "other" - const updated = applyTeamPickInTree(tree, [combined.label], "gamme-lead-mobile"); + const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); expect(updated.map((s) => s.label)).not.toContain(combined.label); - const picked = updated.find((s) => s.label === "gamme-lead-mobile")!; + const picked = updated.find((s) => s.label === "tribe-b")!; expect(picked).toBeDefined(); expect(picked.children).toHaveLength(2); const squadChild = picked.children!.find((c) => c.label === "squad-core + squad-mobile")!; @@ -640,17 +624,13 @@ describe("applyTeamPickInTree", () => { it("merges the picked subtree's children into an existing target section's children", () => { const groups = [ - makeGroup("org/existing", ["gamme-lead-mobile", "squad-existing"]), - makeGroup("org/tools-mobile", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", - "squad-core", - ]), + makeGroup("org/existing", ["tribe-b", "squad-existing"]), + makeGroup("org/tools-mobile", ["tribe-b", "tribe-b-security-p1", "squad-core"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - const updated = applyTeamPickInTree(tree, [combined.label], "gamme-lead-mobile"); - const picked = updated.find((s) => s.label === "gamme-lead-mobile")!; + const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); + const picked = updated.find((s) => s.label === "tribe-b")!; const childLabels = picked.children!.map((c) => c.label).toSorted(); expect(childLabels).toEqual(["squad-core", "squad-existing"]); }); @@ -667,13 +647,13 @@ describe("applyTeamPickInTree", () => { describe("undoSectionPickInTree", () => { it("restores every repo tagged with the matching pickedFrom back to the combined section", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const restored = undoSectionPickInTree(picked, "gamme-client > squad-a + squad-b"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const restored = undoSectionPickInTree(picked, "tribe-a > squad-a + squad-b"); + const gamme = restored.find((s) => s.label === "tribe-a")!; const childLabels = gamme.children!.map((c) => c.label).toSorted(); expect(childLabels).toEqual(["squad-a", "squad-a + squad-b"]); const combined = gamme.children!.find((c) => c.label === "squad-a + squad-b")!; @@ -682,11 +662,11 @@ describe("undoSectionPickInTree", () => { }); it("drops a section left empty after the restore", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const restored = undoSectionPickInTree(picked, "gamme-client > squad-a + squad-b"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const restored = undoSectionPickInTree(picked, "tribe-a > squad-a + squad-b"); + const gamme = restored.find((s) => s.label === "tribe-a")!; // squad-a only ever held the moved repo — it must be gone after the restore. expect(gamme.children!.map((c) => c.label)).not.toContain("squad-a"); }); @@ -723,13 +703,13 @@ describe("undoSectionPickInTree", () => { describe("moveRepoToSectionInTree", () => { it("moves a repo to a sibling under the given parent path", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const moved = moveRepoToSectionInTree(picked, "org/shared", ["gamme-client"], "squad-b"); - const gamme = moved.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const moved = moveRepoToSectionInTree(picked, "org/shared", ["tribe-a"], "squad-b"); + const gamme = moved.find((s) => s.label === "tribe-a")!; const squadB = gamme.children!.find((c) => c.label === "squad-b")!; expect(squadB.groups.map((g) => g.repoFullName)).toEqual(["org/shared"]); const squadA = gamme.children!.find((c) => c.label === "squad-a")!; @@ -737,11 +717,11 @@ describe("moveRepoToSectionInTree", () => { }); it("creates the target section when it doesn't exist yet", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const moved = moveRepoToSectionInTree(picked, "org/shared", ["gamme-client"], "squad-c"); - const gamme = moved.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const moved = moveRepoToSectionInTree(picked, "org/shared", ["tribe-a"], "squad-c"); + const gamme = moved.find((s) => s.label === "tribe-a")!; expect(gamme.children!.map((c) => c.label)).toContain("squad-c"); }); @@ -757,13 +737,13 @@ describe("moveRepoToSectionInTree", () => { describe("undoPickedRepoInTree", () => { it("restores a single picked repo back to its original combined section", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); const restored = undoPickedRepoInTree(picked, "org/shared"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const gamme = restored.find((s) => s.label === "tribe-a")!; const combined = gamme.children!.find((c) => c.label === "squad-a + squad-b")!; expect(combined.groups.map((g) => g.repoFullName)).toEqual(["org/shared"]); expect(combined.groups[0].pickedFrom).toBeUndefined(); @@ -795,9 +775,9 @@ describe("findCombinedSectionPaths", () => { }); it("finds a nested combined section with its full ancestor path", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - expect(findCombinedSectionPaths(tree)).toEqual([["gamme-client", "squad-a + squad-b"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a", "squad-a + squad-b"]]); }); it("returns an empty array when there is no combined section", () => { @@ -808,14 +788,14 @@ describe("findCombinedSectionPaths", () => { it("finds multiple combined sections across different branches", () => { const groups = [ - makeGroup("org/a", ["gamme-x", "squad-a", "squad-b"]), - makeGroup("org/b", ["gamme-y", "chapter-a", "chapter-b"]), + makeGroup("org/a", ["tribe-x", "squad-a", "squad-b"]), + makeGroup("org/b", ["tribe-y", "chapter-a", "chapter-b"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["gamme-"]]); - // Both repos start with a different top-level "gamme-" match, so this + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["tribe-"]]); + // Both repos start with a different top-level "tribe-" match, so this // exercises two independent combined sections at the same nested depth. const paths = findCombinedSectionPaths(tree); - expect(paths).toContainEqual(["gamme-x", "squad-a + squad-b"]); + expect(paths).toContainEqual(["tribe-x", "squad-a + squad-b"]); }); }); @@ -823,13 +803,13 @@ describe("findCombinedSectionPaths", () => { describe("autoPickTeamsByCommonPrefix", () => { it("resolves a combined section to the team that is a prefix of the other", () => { - const groups = [makeGroup("org/a", ["gamme-lead-client", "gamme-lead-client-p1"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); - expect(findCombinedSectionPaths(tree)).toEqual([["gamme-lead-client + gamme-lead-client-p1"]]); + const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); + expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a + tribe-a-p1"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - const winner = resolved.find((s) => s.label === "gamme-lead-client")!; + const winner = resolved.find((s) => s.label === "tribe-a")!; expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); }); @@ -841,22 +821,22 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("picks the shortest common-prefix team among 3+ combined teams", () => { - const groups = [makeGroup("org/a", ["gamme-x", "gamme-x-y", "gamme-x-y-z"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-x", "tribe-x-y", "tribe-x-y-z"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - expect(resolved.map((s) => s.label)).toEqual(["gamme-x"]); + expect(resolved.map((s) => s.label)).toEqual(["tribe-x"]); }); it("resolves independently at a nested (non-top-level) depth", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-a", "squad-a-legacy"]), - makeGroup("org/b", ["gamme-client"]), + makeGroup("org/a", ["tribe-a", "squad-a", "squad-a-legacy"]), + makeGroup("org/b", ["tribe-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - const gamme = resolved.find((s) => s.label === "gamme-client")!; + const gamme = resolved.find((s) => s.label === "tribe-a")!; const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; expect(child).toBeDefined(); expect(child.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); @@ -871,8 +851,8 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("is a pure function — does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["gamme-lead-client", "gamme-lead-client-p1"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const before = JSON.stringify(tree); autoPickTeamsByCommonPrefix(tree); expect(JSON.stringify(tree)).toBe(before); @@ -1445,22 +1425,22 @@ describe("parseTeamPrefixChains", () => { }); it("parses a slash-separated chain into a multi-level chain", () => { - expect(parseTeamPrefixChains("gamme-/squad-")).toEqual({ - chains: [["gamme-", "squad-"]], + expect(parseTeamPrefixChains("tribe-/squad-")).toEqual({ + chains: [["tribe-", "squad-"]], warnings: [], }); }); it("parses a mix of a 2-level chain and an independent 1-level chain", () => { - expect(parseTeamPrefixChains("gamme-/squad-,chapter-")).toEqual({ - chains: [["gamme-", "squad-"], ["chapter-"]], + expect(parseTeamPrefixChains("tribe-/squad-,chapter-")).toEqual({ + chains: [["tribe-", "squad-"], ["chapter-"]], warnings: [], }); }); it("trims whitespace around prefixes and levels", () => { - expect(parseTeamPrefixChains(" gamme- / squad- , chapter- ")).toEqual({ - chains: [["gamme-", "squad-"], ["chapter-"]], + expect(parseTeamPrefixChains(" tribe- / squad- , chapter- ")).toEqual({ + chains: [["tribe-", "squad-"], ["chapter-"]], warnings: [], }); }); @@ -1473,8 +1453,8 @@ describe("parseTeamPrefixChains", () => { }); it("drops an empty level from a leading, trailing, or double slash, with a warning", () => { - const { chains, warnings } = parseTeamPrefixChains("/gamme-//squad-/"); - expect(chains).toEqual([["gamme-", "squad-"]]); + const { chains, warnings } = parseTeamPrefixChains("/tribe-//squad-/"); + expect(chains).toEqual([["tribe-", "squad-"]]); expect(warnings.length).toBeGreaterThan(0); expect(warnings[0]).toContain("empty prefix level"); }); @@ -1500,37 +1480,37 @@ describe("resolvePickTeamAssignment", () => { }); it("resolves a nested bare label by finding it anywhere in the tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = resolvePickTeamAssignment(tree, "squad-a + squad-b=squad-a"); - expect(result).toEqual({ path: ["gamme-client", "squad-a + squad-b"], chosen: "squad-a" }); + expect(result).toEqual({ path: ["tribe-a", "squad-a + squad-b"], chosen: "squad-a" }); }); it("accepts an explicit fully-qualified path (parent > combined)", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const result = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-b"); - expect(result).toEqual({ path: ["gamme-client", "squad-a + squad-b"], chosen: "squad-b" }); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const result = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-b"); + expect(result).toEqual({ path: ["tribe-a", "squad-a + squad-b"], chosen: "squad-b" }); }); it("rejects an explicit path whose parent segment doesn't exist in the tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = resolvePickTeamAssignment(tree, "wrong-parent > squad-a + squad-b=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("no combined section found"); - expect((result as { error: string }).error).toContain("gamme-client > squad-a + squad-b"); + expect((result as { error: string }).error).toContain("tribe-a > squad-a + squad-b"); }); it("rejects an explicit path pointing at a section that no longer exists after an earlier pick", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - let tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + let tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); // First pick resolves (and removes) the only combined section. - const first = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-a"); + const first = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-a"); if ("error" in first) throw new Error("unexpected error in test setup"); tree = applyTeamPickInTree(tree, first.path, first.chosen); // Re-using the same (now stale) explicit path must be rejected, not silently no-op. - const second = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-a"); + const second = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-a"); expect("error" in second).toBe(true); }); @@ -1557,20 +1537,20 @@ describe("resolvePickTeamAssignment", () => { it("errors when the bare label is ambiguous across multiple branches", () => { const groups = [ - makeGroup("org/a", ["gamme-x", "squad-a", "squad-b"]), - makeGroup("org/b", ["gamme-y", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-x", "squad-a", "squad-b"]), + makeGroup("org/b", ["tribe-y", "squad-a", "squad-b"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["gamme-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["tribe-"]]); const result = resolvePickTeamAssignment(tree, "squad-a + squad-b=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("ambiguous"); }); it("errors when the combined label is not a multi-team section", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); // Explicit path pointing at a genuine (non-combined) section. - const result = resolvePickTeamAssignment(tree, "gamme-client > squad-a=squad-a"); + const result = resolvePickTeamAssignment(tree, "tribe-a > squad-a=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("not a multi-team section"); }); diff --git a/src/group.ts b/src/group.ts index 8d4db23..2955d35 100644 --- a/src/group.ts +++ b/src/group.ts @@ -114,16 +114,16 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti /** * 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 + * per nesting depth: `["tribe-", "squad-"]` groups repos by teams matching + * `tribe-` 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. + * depth (e.g. `tribe-a` becomes the parent of + * `tribe-a-p1`) instead of listing them as unrelated siblings. * Combined-label sections (`"a + b"`) and `"other"` sections are never * auto-nested. * @@ -186,8 +186,8 @@ function applyChainDepth(node: TeamSection, chain: string[], depth: number): Tea /** * 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. + * single-team label at the same `level` (e.g. `tribe-a` becomes the + * parent of `tribe-a-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 @@ -554,7 +554,7 @@ function stripPickedFrom(g: RepoGroup): RepoGroup { /** * Tree-aware equivalent of `applyTeamPick`: reassigns the ENTIRE subtree of * the combined section identified by `combinedPath` (e.g. - * `["gamme-client", "squad-a + squad-b"]`) — its own `groups` *and* any + * `["tribe-a", "squad-a + squad-b"]`) — its own `groups` *and* any * nested `children` (e.g. it was already subdivided by a further chain * level) — to a sibling section named `chosenTeam` at that same depth * (merged into it if it already exists, otherwise created in its place). @@ -720,8 +720,8 @@ export function findCombinedSectionPaths(sections: TeamSection[]): string[][] { /** * Auto-resolves every combined (`"a + b"`) section whose candidate team names * share a single common-prefix "parent" — one team name that is a literal - * string-prefix of every other team name in the combo (e.g. `"gamme-lead- - * client"` for `"gamme-lead-client + gamme-lead-client-p1"`) — applying the + * string-prefix of every other team name in the combo (e.g. `"tribe-lead- + * client"` for `"tribe-a + tribe-a-p1"`) — applying the * same tree update as an explicit `--pick-team` assignment. Combined sections * with no such prefix relationship (e.g. `"squad-frontend + squad-mobile"`) * are left combined and unresolved, same as today. @@ -771,8 +771,8 @@ function pathExistsInTree(sections: TeamSection[], path: string[]): boolean { /** * Parses the `--group-by-team-prefix` value into one or more prefix chains * for `groupByTeamHierarchy`: `,` separates independent chains, `/` separates - * nesting levels within one chain. E.g. `"gamme-/squad-,chapter-"` produces - * `[["gamme-", "squad-"], ["chapter-"]]`. + * nesting levels within one chain. E.g. `"tribe-/squad-,chapter-"` produces + * `[["tribe-", "squad-"], ["chapter-"]]`. * * Malformed segments (empty chain from a stray/leading/trailing/double `,`, * or an empty level from a stray `/`) are dropped rather than propagated as @@ -821,7 +821,7 @@ export interface ResolvedPickTeam { * `findCombinedSectionPaths` — succeeds only when exactly one match * exists anywhere in the tree; * - a fully-qualified path joined with `" > "` (e.g. - * `"gamme-client > squad-a + squad-b"`), used as-is without validating + * `"tribe-a > squad-a + squad-b"`), used as-is without validating * against `findCombinedSectionPaths` (so it still resolves correctly * right after an earlier assignment already changed the tree shape). * diff --git a/src/output.test.ts b/src/output.test.ts index ad1c07b..894dd0f 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -216,7 +216,7 @@ describe("buildReplayCommand", () => { it("includes --pick-team-auto when pickTeamAuto is true", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; - const opts: ReplayOptions = { groupByTeamPrefix: "gamme-/squad-", pickTeamAuto: true }; + const opts: ReplayOptions = { groupByTeamPrefix: "tribe-/squad-", pickTeamAuto: true }; const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); expect(cmd).toContain("--pick-team-auto"); }); @@ -512,14 +512,14 @@ describe("buildMarkdownOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); - expect(out).toContain("### squad-dashboard"); + expect(out).toContain("## tribe-a"); + expect(out).toContain("### squad-a"); }); it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { @@ -527,13 +527,13 @@ describe("buildMarkdownOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }, { ...makeGroup("myorg/repoB", ["b.ts"]), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }, ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); @@ -557,12 +557,12 @@ describe("buildMarkdownOutput", () => { const groups: RepoGroup[] = [ { ...makeGroup("myorg/repoA", ["a.ts"], { repoSelected: false }), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, makeGroup("myorg/repoB", ["b.ts"]), // same leaf, no sectionPath of its own ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); + expect(out).toContain("## tribe-a"); expect(out).toContain("myorg/repoB"); }); @@ -570,12 +570,12 @@ describe("buildMarkdownOutput", () => { const groups: RepoGroup[] = [ { ...makeGroup("myorg/repoA", ["a.ts"], { extractSelected: [false] }), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, makeGroup("myorg/repoB", ["b.ts"]), ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); + expect(out).toContain("## tribe-a"); }); it("does not lose a flat sectionLabel heading when its bearing repo is deselected", () => { @@ -653,13 +653,13 @@ describe("buildJsonOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); - expect(parsed.results[0].section).toEqual(["gamme-client", "squad-dashboard"]); + expect(parsed.results[0].section).toEqual(["tribe-a", "squad-a"]); }); it("carries the reconstructed path forward to a sibling repo missing the shared ancestor", () => { @@ -667,18 +667,18 @@ describe("buildJsonOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }, { ...makeGroup("myorg/repoB", ["b.ts"]), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }, ]; const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); - expect(parsed.results[0].section).toEqual(["gamme-client", "squad-billing"]); - expect(parsed.results[1].section).toEqual(["gamme-client", "squad-dashboard"]); + expect(parsed.results[0].section).toEqual(["tribe-a", "squad-b"]); + expect(parsed.results[1].section).toEqual(["tribe-a", "squad-a"]); }); it("omits the section field when no sectionPath/sectionLabel is present", () => { @@ -876,7 +876,7 @@ describe("buildOutput", () => { it("threads pickTeamAuto into the replay command", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { - groupByTeamPrefix: "gamme-/squad-", + groupByTeamPrefix: "tribe-/squad-", pickTeamAuto: true, }); const parsed = JSON.parse(out); diff --git a/src/render.test.ts b/src/render.test.ts index c184415..fb5db95 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -403,20 +403,20 @@ describe("buildRows", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }; const rows = buildRows([g1]); expect(rows).toHaveLength(3); // 2 section rows + 1 repo row expect(rows[0]).toMatchObject({ type: "section", - sectionLabel: "gamme-client", + sectionLabel: "tribe-a", sectionLevel: 0, }); expect(rows[1]).toMatchObject({ type: "section", - sectionLabel: "squad-dashboard", + sectionLabel: "squad-a", sectionLevel: 1, }); expect(rows[2]).toMatchObject({ type: "repo", repoIndex: 0 }); @@ -426,34 +426,34 @@ describe("buildRows", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }; const g2 = { ...makeGroup("org/repoB", ["b.ts"], true), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }; const rows = buildRows([g1, g2]); const sectionRows = rows.filter((r) => r.type === "section"); expect(sectionRows.map((r) => `${r.sectionLevel}:${r.sectionLabel}`)).toEqual([ - "0:gamme-client", - "1:squad-billing", - "1:squad-dashboard", + "0:tribe-a", + "1:squad-b", + "1:squad-a", ]); }); it("keeps a pending hierarchical heading across a filtered-out first repo", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }; const g2 = makeGroup("org/repoB", ["b.ts"], true); // same leaf, filtered out below // Filter by path so that repoA (path "a.ts") is hidden but repoB is not. const rows = buildRows([g1, g2], "b.ts", "path", false); expect(rows[0]).toMatchObject({ type: "section", - sectionLabel: "gamme-client", + sectionLabel: "tribe-a", sectionLevel: 0, }); expect(rows[1]).toMatchObject({ type: "repo", repoIndex: 1 }); @@ -2063,14 +2063,14 @@ describe("renderGroups — hierarchical section headings (sectionLevel)", () => const groups = [ { ...makeGroup("org/repoA", ["a.ts"], true), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, ]; const rows = buildRows(groups); const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); - expect(stripped).toContain("── gamme-client"); - expect(stripped).not.toContain(" ── gamme-client"); + expect(stripped).toContain("── tribe-a"); + expect(stripped).not.toContain(" ── tribe-a"); }); it("indents a level-1 heading by 2 spaces relative to the dashes", () => { @@ -2078,15 +2078,15 @@ describe("renderGroups — hierarchical section headings (sectionLevel)", () => { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const rows = buildRows(groups); const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); - expect(stripped).toContain(" ── squad-dashboard"); + expect(stripped).toContain(" ── squad-a"); }); it("increases indentation progressively for each nesting level", () => { @@ -2137,7 +2137,7 @@ describe("renderGroups — team pick mode section bar", () => { { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, + { label: "tribe-a", level: 0 }, { label: "squad-a + squad-b", level: 1 }, ], }, @@ -2148,7 +2148,7 @@ describe("renderGroups — team pick mode section bar", () => { teamPickMode: { active: true, sectionLabel: "squad-a + squad-b", - sectionPath: ["gamme-client", "squad-a + squad-b"], + sectionPath: ["tribe-a", "squad-a + squad-b"], candidates: ["squad-a", "squad-b"], focusedIndex: 0, }, @@ -2162,7 +2162,7 @@ describe("renderGroups — team pick mode section bar", () => { { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-other", level: 0 }, + { label: "tribe-other", level: 0 }, { label: "squad-a + squad-b", level: 1 }, ], }, @@ -2174,7 +2174,7 @@ describe("renderGroups — team pick mode section bar", () => { active: true, sectionLabel: "squad-a + squad-b", // Targets a DIFFERENT parent than the one actually rendered above. - sectionPath: ["gamme-client", "squad-a + squad-b"], + sectionPath: ["tribe-a", "squad-a + squad-b"], candidates: ["squad-a", "squad-b"], focusedIndex: 0, }, From 43f041737c1780f33c4a43a78f4e01c174987cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 08:56:26 +0200 Subject: [PATCH 3/3] Fix 3 team-grouping bugs: chain fallback, overlap combining, redundant sub-teams - groupByTeamHierarchy now tries every level of a chain in order against what earlier levels of that same chain haven't claimed, so a repo matching only squad- (not tribe-) in a tribe-/squad- chain is captured directly instead of falling through to a later chain or 'other'. - Overlapping single-team labels (e.g. tribe-a / tribe-a-p1) are now combined into one section ("tribe-a + tribe-a-p1"), like a multi-team combo, instead of nested into an extra heading level the declared chain didn't ask for. --pick-team / --pick-team-auto resolve it the same way as any other combo. - bucketSingleLevel now drops a team that is a proper prefix-extension of another team already matched by the same repo (e.g. chapter-architect-a when chapter-architect is also present), reducing unwieldy N-way combos down to their genuinely distinct members before the label is even formed. Removed the now-dead nestOverlappingLabels/assignLevels in favor of a flat, union-find-based combineOverlappingLabels. Updated/added group.ts tests to reproduce all 3 reported bugs and confirm the fixes, and updated docs/usage/team-grouping.md accordingly. --- docs/usage/team-grouping.md | 15 ++- github-code-search.ts | 2 +- src/group.test.ts | 259 ++++++++++++++++++++---------------- src/group.ts | 151 ++++++++++++--------- 4 files changed, 241 insertions(+), 186 deletions(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index bc0a693..c40f954 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -1,6 +1,6 @@ # Team grouping -`--group-by-team-prefix` organises result repositories by their GitHub team membership, as a **hierarchy** of headings. It is especially useful in large organisations with multiple gammes, chapters or squads. +`--group-by-team-prefix` organises result repositories by their GitHub team membership, as a **hierarchy** of headings. It is especially useful in large organisations with multiple tribes, chapters or squads. ## Prerequisites @@ -47,20 +47,23 @@ Within **one level** of a chain, repos are bucketed exactly the same way regardl 3. Repos belonging to **3+** matching teams → same, in ascending combination-size order. 4. Repos matching **no team** at this level → collected into an `other` section. +Before bucketing, a team that is a prefix of another team **already matched by the same repo** is dropped from that repo's matching set (e.g. `chapter-architect-a` is redundant when `chapter-architect` is also present) — the broader team already implies the narrower one, so keeping both would only inflate the combined-section label. + Then, for a chain with more levels, **every section produced above is recursively sub-grouped** by the next prefix — including its own `other` bucket, which becomes a nested `other` at the next depth. +Every level of a chain is tried in order against whatever repos the _earlier levels of that same chain_ haven't already claimed — a repo that only matches `squad-` (not `tribe-`) in a `tribe-/squad-` chain still gets its own top-level section from `squad-`, instead of being invisible to the chain and falling through to a later chain or `other`. + Independent chains (separated by `,`) are processed in order, each consuming repos from the pool not yet claimed by an earlier chain. Repos matched by no chain at all end up in a single top-level `other` section. -### Automatic nesting of overlapping team names +### Automatic combining of overlapping team names -Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: +Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool combines them into one section automatically — instead of listing them as unrelated siblings or nesting one under the other: ```text -## tribe-a -### tribe-a-p1 +## tribe-a + tribe-a-p1 ``` -This cascades across any number of overlapping names, and applies independently at every depth of a chain. +This cascades across any number of overlapping names (all merging into one section), and applies independently at every depth of a chain. The combined section behaves exactly like a multi-team combo — `--pick-team` and [`--pick-team-auto`](#auto-pick-by-common-prefix) can resolve it the same way. ## Non-interactive output diff --git a/github-code-search.ts b/github-code-search.ts index 163bfef..ba22b6c 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -186,7 +186,7 @@ function addSearchOptions(cmd: Command): Command { "tribe-/squad-,chapter-", "Repos are first grouped by single-team match, then multi-team, then the next", "level. Repos matching no prefix go into 'other'. Team names that overlap", - "(e.g. squad-a and squad-a-legacy) are nested automatically.", + "(e.g. squad-a and squad-a-legacy) are combined automatically.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping", ].join("\n"), "", diff --git a/src/group.test.ts b/src/group.test.ts index ddf2f21..1ad6bf7 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -46,7 +46,7 @@ describe("excludeTeamsByPrefix", () => { it("supports multiple exclude prefixes", () => { const groups = [ - makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-aix"]), + makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-a"]), ]; const result = excludeTeamsByPrefix(groups, ["chapter-validators-", "chapter-architect-"]); expect(result[0].teams).toEqual(["chapter-secops"]); @@ -156,6 +156,33 @@ describe("groupByTeamPrefix — multi-team repos", () => { expect(sections).toHaveLength(1); expect(sections[0].groups).toHaveLength(2); }); + + it("fix (#issue: chapter-architect mega-combo): drops redundant sub-teams from a matching-team set before forming the combo label", () => { + // Reported behaviour: a repo tagged with a broad team (chapter-architect) + // AND several of its own narrower variants (chapter-architect-a, + // chapter-architect-b, ...) used to form an unwieldy N-way combined + // section listing every variant. Since the broader team already implies + // membership in each narrower one for grouping purposes, the narrower + // ones are now dropped from the matching set, leaving only the genuinely + // unrelated team (chapter-head-of-frontend) alongside the broad one. + const groups = [ + makeGroup("org/a", [ + "chapter-architect", + "chapter-architect-a", + "chapter-architect-b", + "chapter-architect-c", + "chapter-architect-d", + "chapter-architect-mobile", + "chapter-architect-nodejs", + "chapter-architect-php", + "chapter-architect-python", + "chapter-head-of-frontend", + ]), + ]; + const sections = groupByTeamPrefix(groups, ["chapter-"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("chapter-architect + chapter-head-of-frontend"); + }); }); describe("groupByTeamPrefix — multiple prefixes", () => { @@ -213,17 +240,6 @@ 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"])]; @@ -302,17 +318,15 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { expect(sections[2].groups[0].repoFullName).toBe("org/c"); }); - it("investigation (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (tribe-) match is invisible to that chain and falls through to a later chain", () => { + it("fix (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) is claimed by that level as a fallback, not left invisible to the chain", () => { // Reported behaviour: `fulll/demat-workers` (team squad-c, no tribe- - // team) ends up under a `chapter-` combined section instead of under - // `tribe-/squad-` as its `--group-by-team-prefix tribe-/squad-,chapter-` - // position would suggest. Root cause: a chain only claims a repo if it - // matches chain[0] (tribe-) FIRST — squad- is only ever applied to repos - // already captured under a tribe- section. A repo with a squad- team but - // no tribe- team is entirely invisible to the tribe-/squad- chain and - // falls through to the next chain (or "other") instead — chain - // precedence, as currently implemented, is NOT "any prefix anywhere in - // the chain", only "chain[0], in order". + // team) used to end up under a `chapter-` combined section instead of + // under `tribe-/squad-` as its `--group-by-team-prefix + // tribe-/squad-,chapter-` position would suggest — chain[0] (tribe-) was + // a hard requirement before squad- was even tried. Every level of a + // chain is now tried in order against what earlier levels of *that same + // chain* haven't claimed, so a squad--only repo is captured directly by + // that chain instead of falling through to a later chain or "other". const groups = [ makeGroup("org/demat-workers", [ "squad-c", @@ -321,47 +335,57 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { ]), ]; const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); - expect(sections.map((s) => s.label)).not.toContain("tribe-"); - const chapterCombo = sections.find((s) => s.label.includes("chapter-validators")); - expect(chapterCombo).toBeDefined(); - expect(chapterCombo!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); + const squadC = sections.find((s) => s.label === "squad-c"); + expect(squadC).toBeDefined(); + expect(squadC!.level).toBe(0); + expect(squadC!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); + expect(sections.map((s) => s.label)).not.toContain("other"); + expect(sections.some((s) => s.label.includes("chapter-validators"))).toBe(false); }); }); -describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () => { - it("nests a longer team name under a shorter one that is its prefix", () => { +describe("groupByTeamHierarchy — combining overlapping team names", () => { + it("combines a longer team name with a shorter one that is its prefix into one section", () => { const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("tribe-a"); + expect(sections[0].label).toBe("tribe-a + tribe-a-p1"); expect(sections[0].level).toBe(0); - expect(sections[0].children).toHaveLength(1); - expect(sections[0].children![0].label).toBe("tribe-a-p1"); - expect(sections[0].children![0].level).toBe(1); + expect(sections[0].children).toBeUndefined(); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); - it("cascades nesting across 3 overlapping names", () => { + it("combines a cascading chain of 3 overlapping names into one section", () => { const groups = [ makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"]), makeGroup("org/c", ["tribe-a-p1-x"]), ]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); - expect(collectLabels(sections)).toEqual(["tribe-a", " tribe-a-p1", " tribe-a-p1-x"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("tribe-a + tribe-a-p1 + tribe-a-p1-x"); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual([ + "org/a", + "org/b", + "org/c", + ]); }); - it("does not nest unrelated single-team labels as siblings", () => { + it("does not combine unrelated single-team labels", () => { 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); + const labels = sections.map((s) => s.label); + expect(labels).toContain("squad-front"); + expect(labels).toContain("squad-back"); }); - it("does not nest combined ('a + b') or 'other' sections", () => { + it("does not combine already-combined ('a + b') or 'other' sections into the overlap cluster", () => { 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(" + ")); + const combined = sections.find((s) => s.label === "squad-back + squad-front"); expect(combined).toBeDefined(); - expect(combined!.children ?? []).toHaveLength(0); + const other = sections.find((s) => s.label === "other"); + expect(other).toBeDefined(); }); it("omits the children field entirely on leaf sections instead of an empty array", () => { @@ -370,14 +394,7 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = 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", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; - const sections = groupByTeamHierarchy(groups, [["tribe-"]]); - 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", () => { + it("splits a combined overlap section by the next chain level", () => { const groups = [ makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1", "squad-mobile"]), @@ -386,41 +403,56 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); const parent = sections[0]; - expect(parent.label).toBe("tribe-a"); - // Fully subdivided — none of its own repos are left flat on the parent. + expect(parent.label).toBe("tribe-a + tribe-a-p1"); + // Fully subdivided by the squad- level — none of its own repos are left flat. expect(parent.groups).toEqual([]); const childLabels = (parent.children ?? []).map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["other", "squad-b", "tribe-a-p1"]); - const squadBilling = parent.children!.find((c) => c.label === "squad-b")!; - expect(squadBilling.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + expect(childLabels).toEqual(["other", "squad-b", "squad-mobile"]); + const squadB = parent.children!.find((c) => c.label === "squad-b")!; + expect(squadB.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + const squadMobile = parent.children!.find((c) => c.label === "squad-mobile")!; + expect(squadMobile.groups.map((g) => g.repoFullName)).toEqual(["org/b"]); 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 === "tribe-a-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"]); }); - it("investigation (#issue: tribe-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { + it("fix (#issue: tribe-/squad- + overlapping name): combining no longer inserts an EXTRA heading level beyond the declared chain depth", () => { // Reported behaviour: `--group-by-team-prefix tribe-/squad-` (a 2-level - // chain) renders 3 heading levels — `tribe-a` / - // `tribe-a-security-p1` / `squad-d` — instead of the - // 2 the chain declares. This documents that this is the INTENDED result - // of combining two separate, both-documented mechanisms: automatic - // overlap-nesting of single-team labels (this describe block) PLUS the - // chain's own `/squad-` depth — not a bug in either mechanism alone. + // chain) used to render 3 heading levels — `tribe-a` / + // `tribe-a-security-p1` / `squad-d` — instead of the 2 the chain + // declares. Overlapping single-team labels are now combined into one + // section instead of nested, so the chain's own `/squad-` depth is the + // only extra level that can appear. const groups = [ makeGroup("org/parent-only", ["tribe-a"]), makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); - expect(collectLabels(tree)).toEqual([ - "tribe-a", - " tribe-a-security-p1", - " squad-d", - " other", - ]); + expect(tree).toHaveLength(1); + expect(tree[0].label).toBe("tribe-a + tribe-a-security-p1"); + expect(tree[0].level).toBe(0); + const childLabels = (tree[0].children ?? []).map((c) => c.label).toSorted(); + expect(childLabels).toEqual(["other", "squad-d"]); + const squadD = tree[0].children!.find((c) => c.label === "squad-d")!; + expect(squadD.level).toBe(1); + expect(squadD.groups.map((g) => g.repoFullName)).toEqual(["org/leaf"]); + const other = tree[0].children!.find((c) => c.label === "other")!; + expect(other.groups.map((g) => g.repoFullName)).toEqual(["org/parent-only"]); + }); + + it("fix (#issue: tribe-/squad- + overlapping name): --pick-team-auto fully resolves the combined section to a single heading", () => { + // End-to-end confirmation: combining (this describe block) plus + // --pick-team-auto together produce exactly "## tribe-a", with no + // "## tribe-a-security-p1" heading at any level, as requested. + const groups = [ + makeGroup("org/parent-only", ["tribe-a"]), + makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), + ]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(resolved).toHaveLength(1); + expect(resolved[0].label).toBe("tribe-a"); + expect(findCombinedSectionPaths(resolved)).toEqual([]); }); }); @@ -468,15 +500,15 @@ describe("flattenTeamHierarchy", () => { expect(flat[1].sectionPath).toEqual([{ label: "chapter-backend", level: 0 }]); }); - it("includes a parent's own repos even when it also has nested overlap children", () => { - // "tribe-a" owns org/a directly AND has an overlap-nested - // child "tribe-a-p1" owning org/b — both must appear. + it("flattens a combined overlap section as a single heading for both constituent repos", () => { + // "tribe-a" and "tribe-a-p1" are combined into one "tribe-a + tribe-a-p1" + // section (see combining tests) — both repos share that single heading. const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); const flat = flattenTeamHierarchy(sections); expect(flat.map((g) => g.repoFullName)).toEqual(["org/a", "org/b"]); - expect(flat[0].sectionPath).toEqual([{ label: "tribe-a", level: 0 }]); - expect(flat[1].sectionPath).toEqual([{ label: "tribe-a-p1", level: 1 }]); + expect(flat[0].sectionPath).toEqual([{ label: "tribe-a + tribe-a-p1", level: 0 }]); + expect(flat[1].sectionPath).toBeUndefined(); }); it("does not mutate the input tree", () => { @@ -587,23 +619,20 @@ describe("applyTeamPickInTree", () => { }); it("preserves the picked section's own children (does not drop the subtree)", () => { - // Regression: a top-level combined section ("tribe-a + tribe-a-security-p1") - // that was already subdivided by the next chain level (squad-) must keep - // its nested children when picked — only its own (now empty) `groups` - // were carried over before the fix, silently dropping every repo nested - // underneath. + // Regression: a top-level combined section ("tribe-b + tribe-b-p1") that + // was already subdivided by the next chain level (squad-) must keep its + // nested children when picked — only its own (now empty) `groups` were + // carried over before the fix, silently dropping every repo nested + // underneath. Each repo has only ONE tribe- team (never both), so the + // combined section is formed by combineOverlappingLabels across the two + // repos, not collapsed away by the redundant-sub-team reduction. const groups = [ - makeGroup("org/tools-mobile", [ - "tribe-b", - "tribe-b-security-p1", - "squad-core", - "squad-mobile", - ]), - makeGroup("org/wizard-mobile", ["tribe-b", "tribe-b-security-p1"]), + makeGroup("org/a", ["tribe-b", "squad-core", "squad-mobile"]), + makeGroup("org/b", ["tribe-b-p1"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - expect(combined.label).toBe("tribe-b + tribe-b-security-p1"); + expect(combined.label).toBe("tribe-b + tribe-b-p1"); expect(combined.groups).toEqual([]); // fully subdivided by squad- before the pick expect(combined.children).toHaveLength(2); // "squad-core + squad-mobile" and "other" @@ -614,27 +643,14 @@ describe("applyTeamPickInTree", () => { expect(picked).toBeDefined(); expect(picked.children).toHaveLength(2); const squadChild = picked.children!.find((c) => c.label === "squad-core + squad-mobile")!; - expect(squadChild.groups.map((g) => g.repoFullName)).toEqual(["org/tools-mobile"]); + expect(squadChild.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); const otherChild = picked.children!.find((c) => c.label === "other")!; - expect(otherChild.groups.map((g) => g.repoFullName)).toEqual(["org/wizard-mobile"]); + expect(otherChild.groups.map((g) => g.repoFullName)).toEqual(["org/b"]); // Every repo in the moved subtree is tagged, not just the top node's own groups. expect(squadChild.groups[0].pickedFrom).toBe(combined.label); expect(otherChild.groups[0].pickedFrom).toBe(combined.label); }); - it("merges the picked subtree's children into an existing target section's children", () => { - const groups = [ - makeGroup("org/existing", ["tribe-b", "squad-existing"]), - makeGroup("org/tools-mobile", ["tribe-b", "tribe-b-security-p1", "squad-core"]), - ]; - const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); - const combined = tree.find((s) => s.label.includes(" + "))!; - const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); - const picked = updated.find((s) => s.label === "tribe-b")!; - const childLabels = picked.children!.map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["squad-core", "squad-existing"]); - }); - it("returns sections unchanged for an empty combinedPath", () => { const groups = [makeGroup("org/a")]; const tree = groupByTeamHierarchy(groups, [["squad-"]]); @@ -803,14 +819,14 @@ describe("findCombinedSectionPaths", () => { describe("autoPickTeamsByCommonPrefix", () => { it("resolves a combined section to the team that is a prefix of the other", () => { - const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a + tribe-a-p1"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); const winner = resolved.find((s) => s.label === "tribe-a")!; - expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(winner.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); it("leaves a combined section unresolved when no team is a prefix of the others", () => { @@ -821,7 +837,11 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("picks the shortest common-prefix team among 3+ combined teams", () => { - const groups = [makeGroup("org/a", ["tribe-x", "tribe-x-y", "tribe-x-y-z"])]; + const groups = [ + makeGroup("org/a", ["tribe-x"]), + makeGroup("org/b", ["tribe-x-y"]), + makeGroup("org/c", ["tribe-x-y-z"]), + ]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); @@ -830,8 +850,8 @@ describe("autoPickTeamsByCommonPrefix", () => { it("resolves independently at a nested (non-top-level) depth", () => { const groups = [ - makeGroup("org/a", ["tribe-a", "squad-a", "squad-a-legacy"]), - makeGroup("org/b", ["tribe-a"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-a-legacy"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); @@ -839,7 +859,7 @@ describe("autoPickTeamsByCommonPrefix", () => { const gamme = resolved.find((s) => s.label === "tribe-a")!; const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; expect(child).toBeDefined(); - expect(child.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(child.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); it("an explicit --pick-team resolution is left untouched (no longer combined) when auto-pick runs after", () => { @@ -851,7 +871,7 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("is a pure function — does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const before = JSON.stringify(tree); autoPickTeamsByCommonPrefix(tree); @@ -868,13 +888,16 @@ describe("autoPickTeamsByCommonPrefix", () => { // Reported expectation: "chapter-secops + chapter-validators-core", // "chapter-head-of-frontend + chapter-secops + chapter-validators-core" // and "chapter-secops + chapter-validators + chapter-validators-core" - // should all collapse under "chapter-secops". None of the 4 distinct - // team names here is a literal string-prefix of the others (secops vs - // validators-core vs validators vs head-of-frontend), so the current - // "one candidate is a prefix of all others" strategy correctly leaves - // all 3 combined and unresolved — merging them would require a - // different algorithm (cluster by shared team membership across - // combos), which is out of scope for this strategy. + // should all collapse under "chapter-secops". None of the distinct team + // names here is a literal string-prefix of every other one in its own + // combo (secops vs validators-core vs validators vs head-of-frontend), so + // the "one candidate is a prefix of all others" strategy correctly leaves + // them combined and unresolved — merging them further would require a + // different algorithm (cluster by shared team membership across combos), + // which is out of scope for this strategy. Note org/c's own combo is + // already reduced from 3-way to 2-way by dropRedundantSubTeams, since + // chapter-validators-core is a redundant sub-team of chapter-validators + // (both present on org/c) — a real improvement, just not full merging. const groups = [ makeGroup("org/a", ["chapter-secops", "chapter-validators-core"]), makeGroup("org/b", ["chapter-head-of-frontend", "chapter-secops", "chapter-validators-core"]), @@ -889,7 +912,7 @@ describe("autoPickTeamsByCommonPrefix", () => { ).toEqual( [ "chapter-head-of-frontend + chapter-secops + chapter-validators-core", - "chapter-secops + chapter-validators + chapter-validators-core", + "chapter-secops + chapter-validators", "chapter-secops + chapter-validators-core", ].toSorted(), ); diff --git a/src/group.ts b/src/group.ts index 2955d35..be13f25 100644 --- a/src/group.ts +++ b/src/group.ts @@ -81,7 +81,9 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCount = new Map(); for (const g of matchingGroups) { - const matchingTeams = (g.teams ?? []).filter((t) => t.startsWith(prefix)); + const matchingTeams = dropRedundantSubTeams( + (g.teams ?? []).filter((t) => t.startsWith(prefix)), + ); const count = matchingTeams.length; if (!byCount.has(count)) byCount.set(count, []); byCount.get(count)!.push(g); @@ -93,8 +95,9 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCombo = new Map(); for (const g of groupsInBucket) { - const matchingTeams = (g.teams ?? []) - .filter((t) => t.startsWith(prefix)) + const matchingTeams = dropRedundantSubTeams( + (g.teams ?? []).filter((t) => t.startsWith(prefix)), + ) .toSorted() .join(" + "); if (!byCombo.has(matchingTeams)) byCombo.set(matchingTeams, []); @@ -109,6 +112,18 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti return sections; } +/** + * Drops any team that is a proper prefix-extension of another team already + * present in `teams` (e.g. `chapter-architect-a` is dropped when + * `chapter-architect` is also present in the same repo's matching teams) — + * the broader team already implies the narrower one for grouping purposes, + * so keeping both only inflates combined-section labels with redundant + * information. Pure — returns a new array. + */ +function dropRedundantSubTeams(teams: string[]): string[] { + return teams.filter((t) => !teams.some((other) => other !== t && t.startsWith(other))); +} + // ─── Hierarchical (nested) team-prefix grouping ─────────────────────────────── /** @@ -120,12 +135,19 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti * (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 + * Within one chain, every level is tried in order against whatever the + * earlier levels of *that same chain* haven't already claimed: a repo + * matching only `chain[1]` (e.g. `squad-`) and not `chain[0]` (e.g. + * `tribe-`) still gets its own top-level section from `chain[1]`, instead of + * being invisible to the chain and falling through to a later chain or + * `"other"`. + * + * On top of the explicit chain depth, this also combines sections whose * single-team label is a prefix of another single-team label at the same - * depth (e.g. `tribe-a` becomes the parent of - * `tribe-a-p1`) instead of listing them as unrelated siblings. - * Combined-label sections (`"a + b"`) and `"other"` sections are never - * auto-nested. + * depth (e.g. `tribe-a` and `tribe-a-p1`) into one `"tribe-a + tribe-a-p1"` + * section, exactly like a multi-team combo — instead of nesting them into + * extra heading levels the declared chain didn't ask for. Combined-label + * sections (`"a + b"`) and `"other"` sections are left as-is. * * 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 @@ -140,11 +162,13 @@ export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): T 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; + for (let i = 0; i < chain.length; i++) { + const siblings = bucketSingleLevel(remaining, chain[i]).map((s) => ({ ...s, level: 0 })); + if (siblings.length === 0) continue; - const nested = nestOverlappingLabels(siblings, 0); - sections.push(...nested.map((s) => applyChainDepth(s, chain, 1))); + const combined = combineOverlappingLabels(siblings, 0); + sections.push(...combined.map((s) => applyChainDepth(s, chain, i + 1))); + } } if (remaining.size > 0) { @@ -156,18 +180,12 @@ export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): T /** * 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. + * 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 { - 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; + return node; } const level = (node.level ?? 0) + 1; @@ -177,65 +195,76 @@ function applyChainDepth(node: TeamSection, chain: string[], depth: number): Tea siblings.push({ label: "other", groups: [...localRemaining], level }); } - const splitChildren = nestOverlappingLabels(siblings, level).map((c) => + const children = combineOverlappingLabels(siblings, level).map((c) => applyChainDepth(c, chain, depth + 1), ); - return { ...node, groups: [], children: [...recursedChildren, ...splitChildren] }; + 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. `tribe-a` becomes the - * parent of `tribe-a-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. + * Combines sections whose single-team `label` is a proper prefix of another + * single-team label at the same `level` (e.g. `tribe-a` and `tribe-a-p1`) + * into one section labelled like a multi-team combo (`"tribe-a + tribe-a-p1"`, + * teams sorted and joined), merging their `groups` — instead of nesting them + * into extra heading levels. This lets `--pick-team` / `--pick-team-auto` + * resolve them exactly like any other combined section. Combined-label + * (`"a + b"`) and `"other"` sections are left untouched. Cascading overlaps + * (A prefix of B prefix of C) merge into a single combined section for the + * whole connected chain, since the string-prefix relation is transitive. + * + * Pure — returns a new flat array, no `children`/nesting introduced here. */ -function nestOverlappingLabels(sections: TeamSection[], level: number): TeamSection[] { +function combineOverlappingLabels(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 })); - const nodeByLabel = new Map( - nestable.map((s) => [s.label, { ...s, level, children: [] }]), - ); + const parent = new Map(); + const find = (label: string): string => { + let root = label; + while (parent.get(root) !== root) root = parent.get(root)!; + return root; + }; + const union = (a: string, b: string): void => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; - 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; - } + for (const s of nestable) parent.set(s.label, s.label); + for (const a of nestable) { + for (const b of nestable) { + if (a.label !== b.label && a.label.startsWith(b.label)) union(a.label, b.label); } - if (bestParent !== undefined) parentOf.set(s.label, bestParent); } - for (const [child, parent] of parentOf) { - nodeByLabel.get(parent)!.children.push(nodeByLabel.get(child)!); + const clusters = new Map(); + for (const s of nestable) { + const root = find(s.label); + if (!clusters.has(root)) clusters.set(root, []); + clusters.get(root)!.push(s); } - 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)); + const combined: TeamSection[] = []; + for (const members of clusters.values()) { + if (members.length === 1) { + combined.push({ ...members[0], level }); + continue; + } + combined.push({ + label: members + .map((m) => m.label) + .toSorted() + .join(" + "), + groups: members.flatMap((m) => m.groups), + level, + }); } - return node; + combined.sort((a, b) => a.label.localeCompare(b.label)); + + return [...combined, ...rest]; } /**