diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index f59a32c..ad49d29 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 (`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 dde817b..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 @@ -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 @@ -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. `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 combines them into one section automatically — instead of listing them as unrelated siblings or nesting one under the other: ```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. +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 @@ -90,20 +93,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 +132,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 +144,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 +196,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 +207,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. @@ -221,6 +224,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..ba22b6c 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, @@ -78,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)); }) @@ -180,16 +181,29 @@ 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.", + "(e.g. squad-a and squad-a-legacy) are combined automatically.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping", ].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 ", [ @@ -197,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", @@ -209,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.", @@ -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..1ad6bf7 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-a"]), + ]; + 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", () => { @@ -101,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", () => { @@ -158,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"])]; @@ -196,24 +267,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); @@ -221,13 +292,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"]); @@ -237,83 +308,84 @@ 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- - // team) ends up under a `chapter-` combined section instead of under - // `gamme-/squad-` as its `--group-by-team-prefix gamme-/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 - // 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". + 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) 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-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 chapterCombo = sections.find((s) => s.label.includes("chapter-validators")); - expect(chapterCombo).toBeDefined(); - expect(chapterCombo!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); + 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", () => { - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); +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("gamme-lead-client"); + 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("gamme-lead-client-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", ["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(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", () => { @@ -322,60 +394,65 @@ 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", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); - expect(sections[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); - expect(sections[0].children).toHaveLength(1); - }); - - it("splits a parent's own groups by the next chain level even when it also has an overlap-nested child", () => { + it("splits a combined overlap section by the next chain level", () => { 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"); - // 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(["gamme-lead-client-p1", "other", "squad-billing"]); - const squadBilling = parent.children!.find((c) => c.label === "squad-billing")!; - expect(squadBilling.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + 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 === "gamme-lead-client-p1")!; - expect(p1.children).toHaveLength(1); - expect(p1.children![0].label).toBe("squad-mobile"); - expect(p1.children![0].groups.map((g) => g.repoFullName)).toEqual(["org/b"]); - }); - - 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 - // 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. + }); + + 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) 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", ["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-"]]); - expect(collectLabels(tree)).toEqual([ - "gamme-lead-client", - " gamme-lead-client-security-p1", - " squad-accounting", - " other", - ]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + 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([]); }); }); @@ -384,62 +461,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-"]]); + 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: "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 + tribe-a-p1", level: 0 }]); + expect(flat[1].sectionPath).toBeUndefined(); }); 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); @@ -455,31 +529,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); }); @@ -511,12 +582,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")!; @@ -524,82 +595,62 @@ 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") - // 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", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", - "squad-core", - "squad-mobile", - ]), - makeGroup("org/wizard-mobile", ["gamme-lead-mobile", "gamme-lead-mobile-security-p1"]), + makeGroup("org/a", ["tribe-b", "squad-core", "squad-mobile"]), + makeGroup("org/b", ["tribe-b-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-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")!; - 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", ["gamme-lead-mobile", "squad-existing"]), - makeGroup("org/tools-mobile", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", - "squad-core", - ]), - ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "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 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-"]]); @@ -612,13 +663,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")!; @@ -627,11 +678,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"); }); @@ -668,13 +719,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")!; @@ -682,11 +733,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"); }); @@ -702,13 +753,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(); @@ -740,9 +791,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", () => { @@ -753,14 +804,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"]); }); }); @@ -768,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", ["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"]), 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 === "gamme-lead-client")!; - expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + const winner = resolved.find((s) => s.label === "tribe-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", () => { @@ -786,25 +837,29 @@ 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"]), + 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([]); - 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"]), + makeGroup("org/b", ["tribe-a", "squad-a-legacy"]), ]; - 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"]); + 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", () => { @@ -816,8 +871,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"]), makeGroup("org/b", ["tribe-a-p1"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const before = JSON.stringify(tree); autoPickTeamsByCommonPrefix(tree); expect(JSON.stringify(tree)).toBe(before); @@ -833,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"]), @@ -854,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(), ); @@ -1390,22 +1448,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: [], }); }); @@ -1418,8 +1476,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"); }); @@ -1445,37 +1503,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); }); @@ -1502,20 +1560,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 5b34017..be13f25 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). * @@ -57,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); @@ -69,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, []); @@ -85,23 +112,42 @@ 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 ─────────────────────────────── /** * 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 + * 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. `gamme-lead-client` becomes the parent of - * `gamme-lead-client-p1`) instead of listing them as unrelated siblings. - * Combined-label sections (`"a + b"`) and `"other"` sections are never - * auto-nested. + * 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 @@ -116,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) { @@ -132,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; @@ -153,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. `gamme-lead-client` becomes the - * parent of `gamme-lead-client-p1`), instead of leaving them as siblings. - * Combined-label (`"a + b"`) and `"other"` sections are left untouched at - * `level` and passed through unnested. When a chain of overlaps exists - * (A prefix of B prefix of C), nesting cascades and `level` is incremented - * once per hop from the shallowest ancestor. + * 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]; } /** @@ -530,7 +583,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). @@ -696,8 +749,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. @@ -747,8 +800,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 @@ -797,7 +850,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 4c1b5a3..894dd0f 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -198,9 +198,25 @@ 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 }; + 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"); }); @@ -496,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", () => { @@ -511,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()); @@ -541,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"); }); @@ -554,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", () => { @@ -637,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", () => { @@ -651,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", () => { @@ -860,13 +876,23 @@ 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); 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/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, }, 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,