From 9e8bd48f396535b62aa58c31996b3e0085f9d972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 07:13:32 +0200 Subject: [PATCH 1/5] Add --exclude-team-prefixes: exclude noisy team-name prefixes from grouping Filters teams matching given prefixes out of consideration before groupByTeamHierarchy runs, reducing ambiguous combined sections at the source rather than trying to resolve them after the fact (e.g. many chapter-validators-* sub-teams). A repo left with no matching team after exclusion falls into 'other', same as today. Only applies with --group-by-team-prefix; threaded through the replay command. Closes #200 --- docs/reference/cli-options.md | 31 ++++++++++---------- docs/usage/team-grouping.md | 18 ++++++++++++ github-code-search.ts | 32 ++++++++++++++++++++ src/completions.test.ts | 12 ++++++++ src/completions.ts | 6 ++++ src/group.test.ts | 55 +++++++++++++++++++++++++++++++++++ src/group.ts | 24 +++++++++++++++ src/output.test.ts | 26 +++++++++++++++++ src/output.ts | 8 +++++ src/tui.ts | 2 ++ 10 files changed, 199 insertions(+), 15 deletions(-) diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index f59a32c..f2bde04 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -31,21 +31,22 @@ github-code-search completions [--shell ] ## Search options -| Option | Type | Required | Default | Description | -| ----------------------------------- | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | -| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | -| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | -| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | -| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | -| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | -| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | -| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | -| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | -| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | -| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | -| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | -| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | +| Option | Type | Required | Default | Description | +| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | +| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | +| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | +| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | +| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | +| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | +| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | +| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | +| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | +| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | +| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | +| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | +| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | +| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | ## Global options diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index dde817b..f1f04f2 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -221,6 +221,24 @@ github-code-search query "useFeatureFlag" --org fulll \ - An explicit `--pick-team` for the same section always wins: run explicit picks first, then `--pick-team-auto` resolves whatever combined sections remain. - The replay command emits `--pick-team-auto` when it was used, so a session is reproduced exactly. +## Excluding noisy team prefixes + +Some orgs have many closely related, deeply-overlapping team names under one prefix (e.g. `chapter-validators-core`, `chapter-validators-client`, `chapter-validators-frontend-client`, ...). When several of these co-occur on the same repos, `--group-by-team-prefix` produces many distinct combined sections that neither `--pick-team-auto` nor manual `--pick-team` can cleanly resolve, since no single team name is a common prefix of the others. + +`--exclude-team-prefixes` removes matching teams from consideration **before** grouping runs, reducing ambiguous combos at the source: + +```bash +github-code-search query "useFeatureFlag" --org fulll \ + --group-by-team-prefix chapter- \ + --exclude-team-prefixes chapter-validators- \ + --pick-team-auto +``` + +- Comma-separated, same syntax as `--exclude-repositories` / `--exclude-extracts`. +- A repo left with **no matching team** after exclusion falls into `other`, exactly like a repo with no matching team today. +- Only applies with `--group-by-team-prefix`; a warning is emitted (and the flag is a no-op) otherwise. +- The replay command emits `--exclude-team-prefixes` when it was used, so a session is reproduced exactly. + ## Re-pick & undo pick After using `--pick-team` (or the interactive `p` shortcut) to assign a combined section to a team, individual repos marked `◈` can be re-assigned or restored to their original combined section at any time — regardless of how deeply nested the original section was. diff --git a/github-code-search.ts b/github-code-search.ts index 6306bf0..ad51567 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -23,6 +23,7 @@ import { buildOutput } from "./src/output.ts"; import { applyTeamPickInTree, autoPickTeamsByCommonPrefix, + excludeTeamsByPrefix, findCombinedSectionPaths, flattenTeamHierarchy, groupByTeamHierarchy, @@ -190,6 +191,19 @@ function addSearchOptions(cmd: Command): Command { ].join("\n"), "", ) + .option( + "--exclude-team-prefixes ", + [ + "Comma-separated team-name prefixes to exclude from grouping entirely,", + "before combined sections are formed. Useful to filter out noisy/overly", + "granular team names (e.g. many chapter-validators-* sub-teams) that", + "would otherwise produce combined sections --pick-team-auto can't resolve.", + "A repo left with no matching team after exclusion falls into 'other'.", + "Only applies with --group-by-team-prefix.", + "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#excluding-noisy-team-prefixes", + ].join("\n"), + "", + ) .option( "--pick-team ", [ @@ -246,6 +260,7 @@ async function searchAction( includeArchived: boolean; excludeTemplateRepositories: boolean; groupByTeamPrefix: string; + excludeTeamPrefixes?: string; pickTeam: string[]; pickTeamAuto?: boolean; cache: boolean; @@ -377,6 +392,11 @@ async function searchAction( ); } } + if (!opts.groupByTeamPrefix && opts.excludeTeamPrefixes) { + process.stderr.write( + "warning: --exclude-team-prefixes requires --group-by-team-prefix; skipping\n", + ); + } if (opts.groupByTeamPrefix) { const { chains, warnings: chainWarnings } = parseTeamPrefixChains(opts.groupByTeamPrefix); for (const w of chainWarnings) process.stderr.write(`warning: ${w}\n`); @@ -395,6 +415,16 @@ async function searchAction( g.teams = teamMap.get(g.repoFullName) ?? []; } + const excludeTeamPrefixes = opts.excludeTeamPrefixes + ? opts.excludeTeamPrefixes + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + : []; + if (excludeTeamPrefixes.length > 0) { + groups = excludeTeamsByPrefix(groups, excludeTeamPrefixes); + } + let sections = groupByTeamHierarchy(groups, chains); for (const assignment of opts.pickTeam) { @@ -436,6 +466,7 @@ async function searchAction( includeArchived, excludeTemplates, groupByTeamPrefix: opts.groupByTeamPrefix, + excludeTeamPrefixes: opts.excludeTeamPrefixes, pickTeamAuto: opts.pickTeamAuto, regexHint: opts.regexHint, pickTeams: Object.keys(pickTeams).length > 0 ? pickTeams : undefined, @@ -497,6 +528,7 @@ async function searchAction( includeArchived, excludeTemplates, opts.groupByTeamPrefix, + opts.excludeTeamPrefixes ?? "", Boolean(opts.pickTeamAuto), opts.regexHint ?? "", Object.keys(pickTeams).length > 0 ? pickTeams : {}, diff --git a/src/completions.test.ts b/src/completions.test.ts index 60cbba0..033a09b 100644 --- a/src/completions.test.ts +++ b/src/completions.test.ts @@ -35,6 +35,10 @@ describe("generateCompletion", () => { expect(generateCompletion("bash")).toContain("--pick-team-auto"); }); + it("contains --exclude-team-prefixes", () => { + expect(generateCompletion("bash")).toContain("--exclude-team-prefixes"); + }); + it("contains format values (markdown, json)", () => { const script = generateCompletion("bash"); expect(script).toContain("markdown"); @@ -83,6 +87,10 @@ describe("generateCompletion", () => { expect(generateCompletion("zsh")).toContain("--pick-team-auto"); }); + it("contains --exclude-team-prefixes", () => { + expect(generateCompletion("zsh")).toContain("--exclude-team-prefixes"); + }); + it("contains a 'compdef' directive (zsh-style)", () => { const script = generateCompletion("zsh"); expect(script).toContain("compdef "); @@ -119,6 +127,10 @@ describe("generateCompletion", () => { expect(generateCompletion("fish")).toContain("pick-team-auto"); }); + it("contains exclude-team-prefixes", () => { + expect(generateCompletion("fish")).toContain("exclude-team-prefixes"); + }); + it("uses fish 'complete -c' syntax", () => { const script = generateCompletion("fish"); expect(script).toContain("complete -c github-code-search"); diff --git a/src/completions.ts b/src/completions.ts index b30b701..aa9bc7f 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -45,6 +45,12 @@ const OPTIONS = [ takesArg: true, values: [], }, + { + flag: "exclude-team-prefixes", + description: "Comma-separated team-name prefixes to exclude from grouping", + takesArg: true, + values: [], + }, { flag: "pick-team", description: "Assign a combined team section to a single owner (repeatable)", diff --git a/src/group.test.ts b/src/group.test.ts index 0608f2a..137cb5c 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -3,6 +3,7 @@ import { applyTeamPick, applyTeamPickInTree, autoPickTeamsByCommonPrefix, + excludeTeamsByPrefix, findCombinedSectionPaths, flattenTeamHierarchy, flattenTeamSections, @@ -34,6 +35,60 @@ function makeGroup(repo: string, teams: string[] = []): RepoGroup { }; } +// ─── excludeTeamsByPrefix ───────────────────────────────────────────────────── + +describe("excludeTeamsByPrefix", () => { + it("removes teams matching an excluded prefix, keeps the rest", () => { + const groups = [makeGroup("org/a", ["chapter-secops", "chapter-validators-core"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("supports multiple exclude prefixes", () => { + const groups = [ + makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-aix"]), + ]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-", "chapter-architect-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("leaves teams unchanged when no team matches any exclude prefix", () => { + const groups = [makeGroup("org/a", ["chapter-secops"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual(["chapter-secops"]); + }); + + it("returns a repo with an empty teams array when every team is excluded", () => { + const groups = [makeGroup("org/a", ["chapter-validators-core", "chapter-validators-client"])]; + const result = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(result[0].teams).toEqual([]); + }); + + it("is a no-op (same reference) when excludePrefixes is empty", () => { + const groups = [makeGroup("org/a", ["chapter-secops"])]; + expect(excludeTeamsByPrefix(groups, [])).toBe(groups); + }); + + it("does not mutate the input groups or their teams array", () => { + const groups = [makeGroup("org/a", ["chapter-secops", "chapter-validators-core"])]; + const before = JSON.stringify(groups); + excludeTeamsByPrefix(groups, ["chapter-validators-"]); + expect(JSON.stringify(groups)).toBe(before); + }); + + it("reduces a combined section to a single-team section once ambiguity is removed", () => { + const groups = [ + makeGroup("org/a", ["chapter-secops", "chapter-validators-core"]), + makeGroup("org/b", ["chapter-secops"]), + ]; + const filtered = excludeTeamsByPrefix(groups, ["chapter-validators-"]); + const sections = groupByTeamPrefix(filtered, ["chapter-"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("chapter-secops"); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); + }); +}); + // ─── groupByTeamPrefix ──────────────────────────────────────────────────────── describe("groupByTeamPrefix — basic single prefix", () => { diff --git a/src/group.ts b/src/group.ts index 5b34017..8d4db23 100644 --- a/src/group.ts +++ b/src/group.ts @@ -2,6 +2,30 @@ import type { RepoGroup, TeamSection } from "./types.ts"; // ─── Team-prefix grouping ───────────────────────────────────────────────────── +/** + * Removes any team whose name starts with one of `excludePrefixes` from every + * repo's `teams` list, before grouping runs. Lets noisy/overly granular team + * prefixes (e.g. many `chapter-validators-*` sub-teams) be excluded from + * consideration entirely, reducing ambiguous combined sections at the source + * rather than trying to resolve them after the fact. + * + * Matching is case-sensitive `startsWith`, same as `bucketSingleLevel`'s + * `--group-by-team-prefix` matching, for consistency. A repo left with no + * matching teams behaves exactly like a repo with no matching team today + * (falls into `"other"` once grouped). + * + * Pure — returns new `RepoGroup` objects; does not mutate `groups` or its + * elements. No-op (repos returned unchanged, but still copied) when + * `excludePrefixes` is empty. + */ +export function excludeTeamsByPrefix(groups: RepoGroup[], excludePrefixes: string[]): RepoGroup[] { + if (excludePrefixes.length === 0) return groups; + return groups.map((g) => ({ + ...g, + teams: (g.teams ?? []).filter((t) => !excludePrefixes.some((p) => t.startsWith(p))), + })); +} + /** * Groups `RepoGroup[]` by GitHub team prefix(es). * diff --git a/src/output.test.ts b/src/output.test.ts index 4c1b5a3..ad1c07b 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -198,6 +198,22 @@ describe("buildReplayCommand", () => { expect(cmd).not.toContain("--group-by-team-prefix"); }); + it("includes --exclude-team-prefixes when set", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const opts: ReplayOptions = { + groupByTeamPrefix: "chapter-", + excludeTeamPrefixes: "chapter-validators-", + }; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).toContain("--exclude-team-prefixes 'chapter-validators-'"); + }); + + it("does not include --exclude-team-prefixes when unset (default)", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); + expect(cmd).not.toContain("--exclude-team-prefixes"); + }); + it("includes --pick-team-auto when pickTeamAuto is true", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const opts: ReplayOptions = { groupByTeamPrefix: "gamme-/squad-", pickTeamAuto: true }; @@ -867,6 +883,16 @@ describe("buildOutput", () => { expect(parsed.replayCommand).toContain("--pick-team-auto"); }); + it("threads excludeTeamPrefixes into the replay command", () => { + const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; + const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { + groupByTeamPrefix: "chapter-", + excludeTeamPrefixes: "chapter-validators-", + }); + const parsed = JSON.parse(out); + expect(parsed.replayCommand).toContain("--exclude-team-prefixes 'chapter-validators-'"); + }); + it("threads --group-by-team-prefix into json replay command", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { diff --git a/src/output.ts b/src/output.ts index 6e46be3..a80f50a 100644 --- a/src/output.ts +++ b/src/output.ts @@ -35,6 +35,9 @@ export interface ReplayOptions { includeArchived?: boolean; excludeTemplates?: boolean; groupByTeamPrefix?: string; + /** Mirrors `--exclude-team-prefixes` — excludes matching teams from + * consideration before grouping runs (see `excludeTeamsByPrefix`). */ + excludeTeamPrefixes?: string; /** Mirrors `--pick-team-auto` — auto-resolves combined sections by common * prefix (see `autoPickTeamsByCommonPrefix`). */ pickTeamAuto?: boolean; @@ -62,6 +65,7 @@ export function buildReplayCommand( includeArchived, excludeTemplates, groupByTeamPrefix, + excludeTeamPrefixes, pickTeamAuto, regexHint, pickTeams, @@ -114,6 +118,9 @@ export function buildReplayCommand( if (groupByTeamPrefix) { parts.push(`--group-by-team-prefix ${shellQuote(groupByTeamPrefix)}`); } + if (excludeTeamPrefixes) { + parts.push(`--exclude-team-prefixes ${shellQuote(excludeTeamPrefixes)}`); + } if (pickTeamAuto) { parts.push("--pick-team-auto"); } @@ -400,6 +407,7 @@ export function buildOutput( | "includeArchived" | "excludeTemplates" | "groupByTeamPrefix" + | "excludeTeamPrefixes" | "pickTeamAuto" | "regexHint" | "pickTeams" diff --git a/src/tui.ts b/src/tui.ts index 58269ca..cbfd389 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -143,6 +143,7 @@ export async function runInteractive( includeArchived = false, excludeTemplates = false, groupByTeamPrefix = "", + excludeTeamPrefixes = "", pickTeamAuto = false, regexHint = "", initialPickTeams: Record = {}, @@ -702,6 +703,7 @@ export async function runInteractive( includeArchived, excludeTemplates, groupByTeamPrefix, + excludeTeamPrefixes, pickTeamAuto, regexHint: regexHint || undefined, pickTeams: Object.keys(confirmedPicks).length > 0 ? confirmedPicks : undefined, From 21eefd4f02bade6bf7a637bba2c0741e77988f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 08:24:18 +0200 Subject: [PATCH 2/5] Use generic tribe-/squad- example names instead of org-specific ones Replaces gamme- (Fulll-specific French term) with tribe- (Spotify model terminology) across docs, CLI help text, and tests, and genericizes explicit product/business squad names (squad-dashboard, squad-billing, squad-demat, squad-accounting, squad-bank) to squad-a/b/c/d. chapter- and its specialty names (chapter-backend, chapter-architect, etc.) are kept as-is since they already read as generic engineering domains, not organization-specific identifiers. --- docs/reference/cli-options.md | 32 +-- docs/usage/team-grouping.md | 40 ++-- github-code-search.ts | 12 +- src/group.test.ts | 394 ++++++++++++++++------------------ src/group.ts | 24 +-- src/output.test.ts | 42 ++-- src/render.test.ts | 44 ++-- 7 files changed, 284 insertions(+), 304 deletions(-) diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index f2bde04..ad49d29 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -31,22 +31,22 @@ github-code-search completions [--shell ] ## Search options -| Option | Type | Required | Default | Description | -| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | -| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | -| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | -| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | -| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | -| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | -| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | -| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | -| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`gamme-/squad-`), `,` separates independent chains (`gamme-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | -| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | -| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | -| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `gamme-lead-client + gamme-lead-client-p1` → `gamme-lead-client`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | -| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | -| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | +| Option | Type | Required | Default | Description | +| ------------------------------------ | --------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--org ` | string | ✅ | — | GitHub organization to search in. Automatically injected as `org:` in the query. | +| `--exclude-repositories ` | string | ❌ | `""` | Comma-separated list of repositories to exclude. Short form (`repoA,repoB`) or full form (`org/repoA,org/repoB`) both accepted. | +| `--exclude-extracts ` | string | ❌ | `""` | Comma-separated extract refs to exclude. Format: `repoName:path/to/file:index`. Short form (without org prefix) accepted. | +| `--no-interactive` | boolean (flag) | ❌ | `true` (on) | Disable interactive mode. Interactive mode is **on** by default; pass this flag to disable it. Also triggered by `CI=true`. | +| `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | +| `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | +| `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | +| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | +| `--group-by-team-prefix ` | string | ❌ | `""` | Team-name prefixes for grouping result repos by GitHub team, as a hierarchy. `/` nests levels within one chain (`tribe-/squad-`), `,` separates independent chains (`tribe-/squad-,chapter-`). Requires `read:org` scope. See [Team grouping](/usage/team-grouping). | +| `--exclude-team-prefixes ` | string | ❌ | `""` | Comma-separated team-name prefixes to exclude from grouping entirely, before combined sections are formed. Useful to filter out noisy/overly granular team names. A repo left with no matching team after exclusion falls into `other`. Only applies with `--group-by-team-prefix`. See [Excluding noisy team prefixes](/usage/team-grouping#excluding-noisy-team-prefixes). | +| `--pick-team ` | string (repeatable) | ❌ | — | Assign a combined team section to a single owner. Format: `""=`, where the combined side is a bare label (auto-resolved when unambiguous) or a fully-qualified `"parent > combined"` path. Repeatable — one flag per combined section. Only applies with `--group-by-team-prefix`. See [Team pick mode](/usage/team-grouping#team-pick-mode). | +| `--pick-team-auto` | boolean (flag) | ❌ | `false` | Auto-resolve combined team sections whose team names share a common prefix (e.g. `tribe-a + tribe-a-p1` → `tribe-a`), at every hierarchy depth. Combos with no common-prefix team are left unresolved. An explicit `--pick-team` for the same section always wins. Only applies with `--group-by-team-prefix`. See [Auto-pick by common prefix](/usage/team-grouping#auto-pick-by-common-prefix). | +| `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | +| `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | ## Global options diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index f1f04f2..bc0a693 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -25,18 +25,18 @@ The value of `--group-by-team-prefix` is a small grammar: - `,` separates **independent chains** — each is grouped on its own, in order, against whatever repos the previous chains haven't already claimed. ```bash -# One 2-level chain: group by gamme- first, then by squad- within each gamme +# One 2-level chain: group by tribe- first, then by squad- within each gamme github-code-search "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme-/squad- + --group-by-team-prefix tribe-/squad- ``` ```bash -# A 2-level chain (gamme-/squad-) plus an independent 1-level chain (chapter-) +# A 2-level chain (tribe-/squad-) plus an independent 1-level chain (chapter-) github-code-search "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme-/squad-,chapter- + --group-by-team-prefix tribe-/squad-,chapter- ``` -A chain can have as many levels as you need (`gamme-/squad-/chapter-`, …). Malformed segments (a stray leading/trailing/double `,` or `/`) are dropped with a warning on stderr rather than silently producing an empty prefix. +A chain can have as many levels as you need (`tribe-/squad-/chapter-`, …). Malformed segments (a stray leading/trailing/double `,` or `/`) are dropped with a warning on stderr rather than silently producing an empty prefix. ## Grouping algorithm @@ -53,11 +53,11 @@ Independent chains (separated by `,`) are processed in order, each consuming rep ### Automatic nesting of overlapping team names -Within one level, if a team's name is a **prefix of another team's name** (e.g. `gamme-lead-client` and `gamme-lead-client-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: +Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: ```text -## gamme-lead-client -### gamme-lead-client-p1 +## tribe-a +### tribe-a-p1 ``` This cascades across any number of overlapping names, and applies independently at every depth of a chain. @@ -90,20 +90,20 @@ This cascades across any number of overlapping names, and applies independently - [ ] [src/legacy.js:5:1](https://github.com/fulll/legacy-monolith/blob/main/src/legacy.js#L5) ``` -### Nested (`gamme-/squad-`) output +### Nested (`tribe-/squad-`) output Nested levels render as consecutive markdown headings (`##`, `###`, `####`, …, capped at H6) — a sibling section that shares an ancestor with the previous one doesn't repeat that ancestor's heading: ```text 7 repos · 7 files · 8 matches selected -## gamme-lead-client -### squad-bank +## tribe-a +### squad-a - **fulll/bank** (1 match) - [ ] [src/index.ts:3:14](https://github.com/fulll/bank/blob/main/src/index.ts#L3) -## gamme-lead-mobile +## tribe-b ### squad-core + squad-mobile - **fulll/tools-mobile** (1 match) @@ -129,7 +129,7 @@ Each result carries its full hierarchy path (root first) in a `section` array: "results": [ { "repo": "fulll/tools-mobile", - "section": ["gamme-lead-mobile", "squad-core + squad-mobile"], + "section": ["tribe-b", "squad-core + squad-mobile"], "matches": [{ "path": "src/index.ts", "url": "...", "line": 1, "col": 1 }] } ] @@ -141,10 +141,10 @@ Each result carries its full hierarchy path (root first) in a `section` array: In the TUI, team sections appear as separator lines between repository rows, indented by 2 spaces per nesting level: ```text -── gamme-lead-client - ── squad-bank +── tribe-a + ── squad-a ▶ ◉ fulll/bank (1 match) -── gamme-lead-mobile +── tribe-b ── squad-core + squad-mobile ▶ ◉ fulll/tools-mobile (1 match) ── other @@ -193,7 +193,7 @@ The combined label can be: - **A fully-qualified path**, joined with `>`, when the label is ambiguous or you'd rather be explicit: ```bash - --pick-team "gamme-lead-client > squad-a + squad-b"=squad-a + --pick-team "tribe-a > squad-a + squad-b"=squad-a ``` The flag is repeatable — add one `--pick-team` per combined section to resolve. The replay command emits `--pick-team` automatically (with a fully-qualified path when the pick was made on a nested section) when a pick was confirmed in the TUI. @@ -204,16 +204,16 @@ If the combined label or path is not found (typo, ambiguous, or the section was ## Auto-pick by common prefix -Many combined sections aren't actually ambiguous: when one of the team names is a literal prefix of every other team name in the combo (e.g. `gamme-lead-client` and `gamme-lead-client-p1`), the "parent" team is the obvious owner. `--pick-team-auto` resolves these automatically, without needing a manual `--pick-team`: +Many combined sections aren't actually ambiguous: when one of the team names is a literal prefix of every other team name in the combo (e.g. `tribe-a` and `tribe-a-p1`), the "parent" team is the obvious owner. `--pick-team-auto` resolves these automatically, without needing a manual `--pick-team`: ```bash github-code-search query "useFeatureFlag" --org fulll \ - --group-by-team-prefix gamme- \ + --group-by-team-prefix tribe- \ --pick-team-auto ``` ```text -## gamme-lead-client + gamme-lead-client-p1 → ## gamme-lead-client +## tribe-a + tribe-a-p1 → ## tribe-a ``` - Combos with **no common-prefix team** (e.g. `squad-frontend + squad-mobile` — neither is a prefix of the other) are left combined and unresolved, exactly like today. diff --git a/github-code-search.ts b/github-code-search.ts index ad51567..163bfef 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -79,7 +79,7 @@ function colorDesc(s: string): string { // would make Commander's Help.preformatted() (newline followed by // whitespace) treat the WHOLE description as already manually indented // and skip aligning continuation lines to the option column. - if (/^\s*(e\.g\.|repoA|myorg\/|squad-|chapter-|gamme-)/.test(line)) return style.dim(line); + if (/^\s*(e\.g\.|repoA|myorg\/|squad-|chapter-|tribe-)/.test(line)) return style.dim(line); // Colorize any remaining bare URL (http/https) anywhere in the line return line.replace(/(https?:\/\/\S+)/g, (url) => style.style(["cyan", "underline"], url)); }) @@ -181,9 +181,9 @@ function addSearchOptions(cmd: Command): Command { "--group-by-team-prefix ", [ "Comma-separated team-name prefixes used to group result repos by GitHub team.", - "Use / within one entry to nest levels: gamme-/squad- groups by gamme- first,", + "Use / within one entry to nest levels: tribe-/squad- groups by tribe- first,", "then sub-groups each section by squad-. Combine independent chains with ,:", - "gamme-/squad-,chapter-", + "tribe-/squad-,chapter-", "Repos are first grouped by single-team match, then multi-team, then the next", "level. Repos matching no prefix go into 'other'. Team names that overlap", "(e.g. squad-a and squad-a-legacy) are nested automatically.", @@ -211,7 +211,7 @@ function addSearchOptions(cmd: Command): Command { 'Format: "combined label"=chosenTeam (the = separator is required).', 'Example: --pick-team "squad-frontend + squad-mobile"=squad-frontend', "The combined label may be unqualified (auto-resolved when unambiguous)", - 'or a full path when nested / ambiguous: "gamme-client > squad-a + squad-b"=squad-a', + 'or a full path when nested / ambiguous: "tribe-a > squad-a + squad-b"=squad-a', "Repeatable — one flag per combined section to resolve.", "Only applies with --group-by-team-prefix.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#team-pick-mode", @@ -223,8 +223,8 @@ function addSearchOptions(cmd: Command): Command { "--pick-team-auto", [ "Auto-resolve combined team sections whose team names share a common", - 'prefix (e.g. "gamme-lead-client + gamme-lead-client-p1" \u2192 auto-picks', - '"gamme-lead-client"), without needing an explicit --pick-team.', + 'prefix (e.g. "tribe-a + tribe-a-p1" \u2192 auto-picks', + '"tribe-a"), without needing an explicit --pick-team.', "Combos with no common-prefix team (e.g. squad-a + squad-b) are left", "unresolved. An explicit --pick-team for the same section always wins.", "Applies at every hierarchy depth. Only applies with --group-by-team-prefix.", diff --git a/src/group.test.ts b/src/group.test.ts index 137cb5c..ddf2f21 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -251,24 +251,24 @@ describe("groupByTeamHierarchy — single-level chain (parity with groupByTeamPr describe("groupByTeamHierarchy — 2-level chain", () => { it("groups by the first prefix, then sub-groups each section by the second", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("gamme-client"); + expect(sections[0].label).toBe("tribe-a"); expect(sections[0].level).toBe(0); expect(sections[0].groups).toEqual([]); // subdivided, not a leaf const childLabels = (sections[0].children ?? []).map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["squad-billing", "squad-dashboard"]); + expect(childLabels).toEqual(["squad-a", "squad-b"]); for (const child of sections[0].children ?? []) { expect(child.level).toBe(1); } }); it("repos with no match at the second level fall into a nested 'other'", () => { - const groups = [makeGroup("org/a", ["gamme-client"])]; // no squad- team - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a"])]; // no squad- team + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const child = sections[0].children ?? []; expect(child.map((c) => c.label)).toEqual(["other"]); expect(child[0].level).toBe(1); @@ -276,13 +276,13 @@ describe("groupByTeamHierarchy — 2-level chain", () => { }); it("supports a 3-level chain recursively", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard", "chapter-fe"])]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-", "chapter-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "chapter-fe"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-", "chapter-"]]); const l1 = sections[0]; const l2 = l1.children![0]; const l3 = l2.children![0]; - expect(l1.label).toBe("gamme-client"); - expect(l2.label).toBe("squad-dashboard"); + expect(l1.label).toBe("tribe-a"); + expect(l2.label).toBe("squad-a"); expect(l3.label).toBe("chapter-fe"); expect([l1.level, l2.level, l3.level]).toEqual([0, 1, 2]); expect(l3.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); @@ -292,36 +292,36 @@ describe("groupByTeamHierarchy — 2-level chain", () => { describe("groupByTeamHierarchy — multiple independent chains", () => { it("processes each chain sequentially against the remaining pool", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), makeGroup("org/c", []), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const labels = sections.map((s) => s.label); - expect(labels).toEqual(["gamme-client", "chapter-backend", "other"]); + expect(labels).toEqual(["tribe-a", "chapter-backend", "other"]); expect(sections[2].groups[0].repoFullName).toBe("org/c"); }); - it("investigation (#issue: gamme-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (gamme-) match is invisible to that chain and falls through to a later chain", () => { - // Reported behaviour: `fulll/demat-workers` (team squad-demat, no gamme- + it("investigation (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (tribe-) match is invisible to that chain and falls through to a later chain", () => { + // Reported behaviour: `fulll/demat-workers` (team squad-c, no tribe- // team) ends up under a `chapter-` combined section instead of under - // `gamme-/squad-` as its `--group-by-team-prefix gamme-/squad-,chapter-` + // `tribe-/squad-` as its `--group-by-team-prefix tribe-/squad-,chapter-` // position would suggest. Root cause: a chain only claims a repo if it - // matches chain[0] (gamme-) FIRST — squad- is only ever applied to repos - // already captured under a gamme- section. A repo with a squad- team but - // no gamme- team is entirely invisible to the gamme-/squad- chain and + // matches chain[0] (tribe-) FIRST — squad- is only ever applied to repos + // already captured under a tribe- section. A repo with a squad- team but + // no tribe- team is entirely invisible to the tribe-/squad- chain and // falls through to the next chain (or "other") instead — chain // precedence, as currently implemented, is NOT "any prefix anywhere in // the chain", only "chain[0], in order". const groups = [ makeGroup("org/demat-workers", [ - "squad-demat", + "squad-c", "chapter-validators-client", "chapter-validators-core", ]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); - expect(sections.map((s) => s.label)).not.toContain("gamme-"); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); + expect(sections.map((s) => s.label)).not.toContain("tribe-"); const chapterCombo = sections.find((s) => s.label.includes("chapter-validators")); expect(chapterCombo).toBeDefined(); expect(chapterCombo!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); @@ -330,31 +330,24 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () => { it("nests a longer team name under a shorter one that is its prefix", () => { - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("gamme-lead-client"); + expect(sections[0].label).toBe("tribe-a"); expect(sections[0].level).toBe(0); expect(sections[0].children).toHaveLength(1); - expect(sections[0].children![0].label).toBe("gamme-lead-client-p1"); + expect(sections[0].children![0].label).toBe("tribe-a-p1"); expect(sections[0].children![0].level).toBe(1); }); it("cascades nesting across 3 overlapping names", () => { const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - makeGroup("org/c", ["gamme-lead-client-p1-x"]), + makeGroup("org/a", ["tribe-a"]), + makeGroup("org/b", ["tribe-a-p1"]), + makeGroup("org/c", ["tribe-a-p1-x"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); - expect(collectLabels(sections)).toEqual([ - "gamme-lead-client", - " gamme-lead-client-p1", - " gamme-lead-client-p1-x", - ]); + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); + expect(collectLabels(sections)).toEqual(["tribe-a", " tribe-a-p1", " tribe-a-p1-x"]); }); it("does not nest unrelated single-team labels as siblings", () => { @@ -378,57 +371,54 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = }); it("keeps a parent's own groups when it also has an overlap-nested child", () => { - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); expect(sections[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); expect(sections[0].children).toHaveLength(1); }); it("splits a parent's own groups by the next chain level even when it also has an overlap-nested child", () => { const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1", "squad-mobile"]), - makeGroup("org/c", ["gamme-lead-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a"]), + makeGroup("org/b", ["tribe-a-p1", "squad-mobile"]), + makeGroup("org/c", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); const parent = sections[0]; - expect(parent.label).toBe("gamme-lead-client"); + expect(parent.label).toBe("tribe-a"); // Fully subdivided — none of its own repos are left flat on the parent. expect(parent.groups).toEqual([]); const childLabels = (parent.children ?? []).map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["gamme-lead-client-p1", "other", "squad-billing"]); - const squadBilling = parent.children!.find((c) => c.label === "squad-billing")!; + expect(childLabels).toEqual(["other", "squad-b", "tribe-a-p1"]); + const squadBilling = parent.children!.find((c) => c.label === "squad-b")!; expect(squadBilling.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); const other = parent.children!.find((c) => c.label === "other")!; expect(other.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); // The overlap-nested child was ALSO subdivided by the next chain level. - const p1 = parent.children!.find((c) => c.label === "gamme-lead-client-p1")!; + const p1 = parent.children!.find((c) => c.label === "tribe-a-p1")!; expect(p1.children).toHaveLength(1); expect(p1.children![0].label).toBe("squad-mobile"); expect(p1.children![0].groups.map((g) => g.repoFullName)).toEqual(["org/b"]); }); - it("investigation (#issue: gamme-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { - // Reported behaviour: `--group-by-team-prefix gamme-/squad-` (a 2-level - // chain) renders 3 heading levels — `gamme-lead-client` / - // `gamme-lead-client-security-p1` / `squad-accounting` — instead of the + it("investigation (#issue: tribe-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { + // Reported behaviour: `--group-by-team-prefix tribe-/squad-` (a 2-level + // chain) renders 3 heading levels — `tribe-a` / + // `tribe-a-security-p1` / `squad-d` — instead of the // 2 the chain declares. This documents that this is the INTENDED result // of combining two separate, both-documented mechanisms: automatic // overlap-nesting of single-team labels (this describe block) PLUS the // chain's own `/squad-` depth — not a bug in either mechanism alone. const groups = [ - makeGroup("org/parent-only", ["gamme-lead-client"]), - makeGroup("org/leaf", ["gamme-lead-client-security-p1", "squad-accounting"]), + makeGroup("org/parent-only", ["tribe-a"]), + makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(collectLabels(tree)).toEqual([ - "gamme-lead-client", - " gamme-lead-client-security-p1", - " squad-accounting", + "tribe-a", + " tribe-a-security-p1", + " squad-d", " other", ]); }); @@ -439,62 +429,59 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = describe("flattenTeamHierarchy", () => { it("tags the first repo of a 2-level leaf with both ancestor headings", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-a"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const flat = flattenTeamHierarchy(sections); expect(flat).toHaveLength(2); expect(flat[0].sectionPath).toEqual([ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ]); expect(flat[1].sectionPath).toBeUndefined(); }); it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const flat = flattenTeamHierarchy(sections); - // First leaf (alphabetically squad-billing comes first) gets both headings + // First leaf (alphabetically squad-a comes first) gets both headings expect(flat[0].sectionPath).toEqual([ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ]); - // Second leaf shares the "gamme-client" ancestor — only the new heading is listed - expect(flat[1].sectionPath).toEqual([{ label: "squad-dashboard", level: 1 }]); + // Second leaf shares the "tribe-a" ancestor — only the new heading is listed + expect(flat[1].sectionPath).toEqual([{ label: "squad-b", level: 1 }]); }); it("emits a full new path when moving to an unrelated top-level chain", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), ]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const flat = flattenTeamHierarchy(sections); expect(flat[1].sectionPath).toEqual([{ label: "chapter-backend", level: 0 }]); }); it("includes a parent's own repos even when it also has nested overlap children", () => { - // "gamme-lead-client" owns org/a directly AND has an overlap-nested - // child "gamme-lead-client-p1" owning org/b — both must appear. - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const sections = groupByTeamHierarchy(groups, [["gamme-"]]); + // "tribe-a" owns org/a directly AND has an overlap-nested + // child "tribe-a-p1" owning org/b — both must appear. + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-"]]); const flat = flattenTeamHierarchy(sections); expect(flat.map((g) => g.repoFullName)).toEqual(["org/a", "org/b"]); - expect(flat[0].sectionPath).toEqual([{ label: "gamme-lead-client", level: 0 }]); - expect(flat[1].sectionPath).toEqual([{ label: "gamme-lead-client-p1", level: 1 }]); + expect(flat[0].sectionPath).toEqual([{ label: "tribe-a", level: 0 }]); + expect(flat[1].sectionPath).toEqual([{ label: "tribe-a-p1", level: 1 }]); }); it("does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard"])]; - const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a"])]; + const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const before = JSON.stringify(sections); flattenTeamHierarchy(sections); expect(JSON.stringify(sections)).toBe(before); @@ -510,31 +497,28 @@ describe("flattenTeamHierarchy", () => { describe("rebuildTeamHierarchy", () => { it("round-trips a 2-level tree through flattenTeamHierarchy", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), - makeGroup("org/b", ["gamme-client", "squad-billing"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-b"]), ]; - const original = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const original = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); it("round-trips a tree where a node has both own groups and children (overlap parent)", () => { - const groups = [ - makeGroup("org/a", ["gamme-lead-client"]), - makeGroup("org/b", ["gamme-lead-client-p1"]), - ]; - const original = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; + const original = groupByTeamHierarchy(groups, [["tribe-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); it("round-trips multiple independent top-level chains", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-dashboard"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), makeGroup("org/b", ["chapter-backend"]), makeGroup("org/c", []), ]; - const original = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["chapter-"]]); + const original = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); const rebuilt = rebuildTeamHierarchy(flattenTeamHierarchy(original)); expect(rebuilt).toEqual(original); }); @@ -566,12 +550,12 @@ describe("applyTeamPickInTree", () => { it("reassigns a nested combined section to a sibling at the same depth", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const gamme = updated.find((s) => s.label === "tribe-a")!; const childLabels = (gamme.children ?? []).map((c) => c.label); expect(childLabels).not.toContain("squad-a + squad-b"); const squadA = gamme.children!.find((c) => c.label === "squad-a")!; @@ -579,54 +563,54 @@ describe("applyTeamPickInTree", () => { }); it("tags moved repos with pickedFrom = joined path", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const gamme = updated.find((s) => s.label === "tribe-a")!; const squadA = gamme.children!.find((c) => c.label === "squad-a")!; - expect(squadA.groups[0].pickedFrom).toBe("gamme-client > squad-a + squad-b"); + expect(squadA.groups[0].pickedFrom).toBe("tribe-a > squad-a + squad-b"); }); it("creates a new sibling section when the chosen team has none yet", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const updated = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-b"); - const gamme = updated.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const updated = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-b"); + const gamme = updated.find((s) => s.label === "tribe-a")!; expect(gamme.children!.map((c) => c.label)).toContain("squad-b"); }); it("is a no-op when a path segment is not found", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = applyTeamPickInTree(tree, ["nope", "squad-a + squad-b"], "squad-a"); expect(result).toEqual(tree); }); it("preserves the picked section's own children (does not drop the subtree)", () => { - // Regression: a top-level combined section ("gamme-a + gamme-a-security-p1") + // Regression: a top-level combined section ("tribe-a + tribe-a-security-p1") // that was already subdivided by the next chain level (squad-) must keep // its nested children when picked — only its own (now empty) `groups` // were carried over before the fix, silently dropping every repo nested // underneath. const groups = [ makeGroup("org/tools-mobile", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", + "tribe-b", + "tribe-b-security-p1", "squad-core", "squad-mobile", ]), - makeGroup("org/wizard-mobile", ["gamme-lead-mobile", "gamme-lead-mobile-security-p1"]), + makeGroup("org/wizard-mobile", ["tribe-b", "tribe-b-security-p1"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - expect(combined.label).toBe("gamme-lead-mobile + gamme-lead-mobile-security-p1"); + expect(combined.label).toBe("tribe-b + tribe-b-security-p1"); expect(combined.groups).toEqual([]); // fully subdivided by squad- before the pick expect(combined.children).toHaveLength(2); // "squad-core + squad-mobile" and "other" - const updated = applyTeamPickInTree(tree, [combined.label], "gamme-lead-mobile"); + const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); expect(updated.map((s) => s.label)).not.toContain(combined.label); - const picked = updated.find((s) => s.label === "gamme-lead-mobile")!; + const picked = updated.find((s) => s.label === "tribe-b")!; expect(picked).toBeDefined(); expect(picked.children).toHaveLength(2); const squadChild = picked.children!.find((c) => c.label === "squad-core + squad-mobile")!; @@ -640,17 +624,13 @@ describe("applyTeamPickInTree", () => { it("merges the picked subtree's children into an existing target section's children", () => { const groups = [ - makeGroup("org/existing", ["gamme-lead-mobile", "squad-existing"]), - makeGroup("org/tools-mobile", [ - "gamme-lead-mobile", - "gamme-lead-mobile-security-p1", - "squad-core", - ]), + makeGroup("org/existing", ["tribe-b", "squad-existing"]), + makeGroup("org/tools-mobile", ["tribe-b", "tribe-b-security-p1", "squad-core"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - const updated = applyTeamPickInTree(tree, [combined.label], "gamme-lead-mobile"); - const picked = updated.find((s) => s.label === "gamme-lead-mobile")!; + const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); + const picked = updated.find((s) => s.label === "tribe-b")!; const childLabels = picked.children!.map((c) => c.label).toSorted(); expect(childLabels).toEqual(["squad-core", "squad-existing"]); }); @@ -667,13 +647,13 @@ describe("applyTeamPickInTree", () => { describe("undoSectionPickInTree", () => { it("restores every repo tagged with the matching pickedFrom back to the combined section", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const restored = undoSectionPickInTree(picked, "gamme-client > squad-a + squad-b"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const restored = undoSectionPickInTree(picked, "tribe-a > squad-a + squad-b"); + const gamme = restored.find((s) => s.label === "tribe-a")!; const childLabels = gamme.children!.map((c) => c.label).toSorted(); expect(childLabels).toEqual(["squad-a", "squad-a + squad-b"]); const combined = gamme.children!.find((c) => c.label === "squad-a + squad-b")!; @@ -682,11 +662,11 @@ describe("undoSectionPickInTree", () => { }); it("drops a section left empty after the restore", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const restored = undoSectionPickInTree(picked, "gamme-client > squad-a + squad-b"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const restored = undoSectionPickInTree(picked, "tribe-a > squad-a + squad-b"); + const gamme = restored.find((s) => s.label === "tribe-a")!; // squad-a only ever held the moved repo — it must be gone after the restore. expect(gamme.children!.map((c) => c.label)).not.toContain("squad-a"); }); @@ -723,13 +703,13 @@ describe("undoSectionPickInTree", () => { describe("moveRepoToSectionInTree", () => { it("moves a repo to a sibling under the given parent path", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const moved = moveRepoToSectionInTree(picked, "org/shared", ["gamme-client"], "squad-b"); - const gamme = moved.find((s) => s.label === "gamme-client")!; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const moved = moveRepoToSectionInTree(picked, "org/shared", ["tribe-a"], "squad-b"); + const gamme = moved.find((s) => s.label === "tribe-a")!; const squadB = gamme.children!.find((c) => c.label === "squad-b")!; expect(squadB.groups.map((g) => g.repoFullName)).toEqual(["org/shared"]); const squadA = gamme.children!.find((c) => c.label === "squad-a")!; @@ -737,11 +717,11 @@ describe("moveRepoToSectionInTree", () => { }); it("creates the target section when it doesn't exist yet", () => { - const groups = [makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); - const moved = moveRepoToSectionInTree(picked, "org/shared", ["gamme-client"], "squad-c"); - const gamme = moved.find((s) => s.label === "gamme-client")!; + const groups = [makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); + const moved = moveRepoToSectionInTree(picked, "org/shared", ["tribe-a"], "squad-c"); + const gamme = moved.find((s) => s.label === "tribe-a")!; expect(gamme.children!.map((c) => c.label)).toContain("squad-c"); }); @@ -757,13 +737,13 @@ describe("moveRepoToSectionInTree", () => { describe("undoPickedRepoInTree", () => { it("restores a single picked repo back to its original combined section", () => { const groups = [ - makeGroup("org/shared", ["gamme-client", "squad-a", "squad-b"]), - makeGroup("org/a", ["gamme-client", "squad-a"]), + makeGroup("org/shared", ["tribe-a", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const picked = applyTeamPickInTree(tree, ["gamme-client", "squad-a + squad-b"], "squad-a"); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const picked = applyTeamPickInTree(tree, ["tribe-a", "squad-a + squad-b"], "squad-a"); const restored = undoPickedRepoInTree(picked, "org/shared"); - const gamme = restored.find((s) => s.label === "gamme-client")!; + const gamme = restored.find((s) => s.label === "tribe-a")!; const combined = gamme.children!.find((c) => c.label === "squad-a + squad-b")!; expect(combined.groups.map((g) => g.repoFullName)).toEqual(["org/shared"]); expect(combined.groups[0].pickedFrom).toBeUndefined(); @@ -795,9 +775,9 @@ describe("findCombinedSectionPaths", () => { }); it("finds a nested combined section with its full ancestor path", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - expect(findCombinedSectionPaths(tree)).toEqual([["gamme-client", "squad-a + squad-b"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a", "squad-a + squad-b"]]); }); it("returns an empty array when there is no combined section", () => { @@ -808,14 +788,14 @@ describe("findCombinedSectionPaths", () => { it("finds multiple combined sections across different branches", () => { const groups = [ - makeGroup("org/a", ["gamme-x", "squad-a", "squad-b"]), - makeGroup("org/b", ["gamme-y", "chapter-a", "chapter-b"]), + makeGroup("org/a", ["tribe-x", "squad-a", "squad-b"]), + makeGroup("org/b", ["tribe-y", "chapter-a", "chapter-b"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["gamme-"]]); - // Both repos start with a different top-level "gamme-" match, so this + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["tribe-"]]); + // Both repos start with a different top-level "tribe-" match, so this // exercises two independent combined sections at the same nested depth. const paths = findCombinedSectionPaths(tree); - expect(paths).toContainEqual(["gamme-x", "squad-a + squad-b"]); + expect(paths).toContainEqual(["tribe-x", "squad-a + squad-b"]); }); }); @@ -823,13 +803,13 @@ describe("findCombinedSectionPaths", () => { describe("autoPickTeamsByCommonPrefix", () => { it("resolves a combined section to the team that is a prefix of the other", () => { - const groups = [makeGroup("org/a", ["gamme-lead-client", "gamme-lead-client-p1"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); - expect(findCombinedSectionPaths(tree)).toEqual([["gamme-lead-client + gamme-lead-client-p1"]]); + const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); + expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a + tribe-a-p1"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - const winner = resolved.find((s) => s.label === "gamme-lead-client")!; + const winner = resolved.find((s) => s.label === "tribe-a")!; expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); }); @@ -841,22 +821,22 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("picks the shortest common-prefix team among 3+ combined teams", () => { - const groups = [makeGroup("org/a", ["gamme-x", "gamme-x-y", "gamme-x-y-z"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-x", "tribe-x-y", "tribe-x-y-z"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - expect(resolved.map((s) => s.label)).toEqual(["gamme-x"]); + expect(resolved.map((s) => s.label)).toEqual(["tribe-x"]); }); it("resolves independently at a nested (non-top-level) depth", () => { const groups = [ - makeGroup("org/a", ["gamme-client", "squad-a", "squad-a-legacy"]), - makeGroup("org/b", ["gamme-client"]), + makeGroup("org/a", ["tribe-a", "squad-a", "squad-a-legacy"]), + makeGroup("org/b", ["tribe-a"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - const gamme = resolved.find((s) => s.label === "gamme-client")!; + const gamme = resolved.find((s) => s.label === "tribe-a")!; const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; expect(child).toBeDefined(); expect(child.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); @@ -871,8 +851,8 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("is a pure function — does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["gamme-lead-client", "gamme-lead-client-p1"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const before = JSON.stringify(tree); autoPickTeamsByCommonPrefix(tree); expect(JSON.stringify(tree)).toBe(before); @@ -1445,22 +1425,22 @@ describe("parseTeamPrefixChains", () => { }); it("parses a slash-separated chain into a multi-level chain", () => { - expect(parseTeamPrefixChains("gamme-/squad-")).toEqual({ - chains: [["gamme-", "squad-"]], + expect(parseTeamPrefixChains("tribe-/squad-")).toEqual({ + chains: [["tribe-", "squad-"]], warnings: [], }); }); it("parses a mix of a 2-level chain and an independent 1-level chain", () => { - expect(parseTeamPrefixChains("gamme-/squad-,chapter-")).toEqual({ - chains: [["gamme-", "squad-"], ["chapter-"]], + expect(parseTeamPrefixChains("tribe-/squad-,chapter-")).toEqual({ + chains: [["tribe-", "squad-"], ["chapter-"]], warnings: [], }); }); it("trims whitespace around prefixes and levels", () => { - expect(parseTeamPrefixChains(" gamme- / squad- , chapter- ")).toEqual({ - chains: [["gamme-", "squad-"], ["chapter-"]], + expect(parseTeamPrefixChains(" tribe- / squad- , chapter- ")).toEqual({ + chains: [["tribe-", "squad-"], ["chapter-"]], warnings: [], }); }); @@ -1473,8 +1453,8 @@ describe("parseTeamPrefixChains", () => { }); it("drops an empty level from a leading, trailing, or double slash, with a warning", () => { - const { chains, warnings } = parseTeamPrefixChains("/gamme-//squad-/"); - expect(chains).toEqual([["gamme-", "squad-"]]); + const { chains, warnings } = parseTeamPrefixChains("/tribe-//squad-/"); + expect(chains).toEqual([["tribe-", "squad-"]]); expect(warnings.length).toBeGreaterThan(0); expect(warnings[0]).toContain("empty prefix level"); }); @@ -1500,37 +1480,37 @@ describe("resolvePickTeamAssignment", () => { }); it("resolves a nested bare label by finding it anywhere in the tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = resolvePickTeamAssignment(tree, "squad-a + squad-b=squad-a"); - expect(result).toEqual({ path: ["gamme-client", "squad-a + squad-b"], chosen: "squad-a" }); + expect(result).toEqual({ path: ["tribe-a", "squad-a + squad-b"], chosen: "squad-a" }); }); it("accepts an explicit fully-qualified path (parent > combined)", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); - const result = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-b"); - expect(result).toEqual({ path: ["gamme-client", "squad-a + squad-b"], chosen: "squad-b" }); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const result = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-b"); + expect(result).toEqual({ path: ["tribe-a", "squad-a + squad-b"], chosen: "squad-b" }); }); it("rejects an explicit path whose parent segment doesn't exist in the tree", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const result = resolvePickTeamAssignment(tree, "wrong-parent > squad-a + squad-b=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("no combined section found"); - expect((result as { error: string }).error).toContain("gamme-client > squad-a + squad-b"); + expect((result as { error: string }).error).toContain("tribe-a > squad-a + squad-b"); }); it("rejects an explicit path pointing at a section that no longer exists after an earlier pick", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a", "squad-b"])]; - let tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a", "squad-b"])]; + let tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); // First pick resolves (and removes) the only combined section. - const first = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-a"); + const first = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-a"); if ("error" in first) throw new Error("unexpected error in test setup"); tree = applyTeamPickInTree(tree, first.path, first.chosen); // Re-using the same (now stale) explicit path must be rejected, not silently no-op. - const second = resolvePickTeamAssignment(tree, "gamme-client > squad-a + squad-b=squad-a"); + const second = resolvePickTeamAssignment(tree, "tribe-a > squad-a + squad-b=squad-a"); expect("error" in second).toBe(true); }); @@ -1557,20 +1537,20 @@ describe("resolvePickTeamAssignment", () => { it("errors when the bare label is ambiguous across multiple branches", () => { const groups = [ - makeGroup("org/a", ["gamme-x", "squad-a", "squad-b"]), - makeGroup("org/b", ["gamme-y", "squad-a", "squad-b"]), + makeGroup("org/a", ["tribe-x", "squad-a", "squad-b"]), + makeGroup("org/b", ["tribe-y", "squad-a", "squad-b"]), ]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"], ["gamme-"]]); + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["tribe-"]]); const result = resolvePickTeamAssignment(tree, "squad-a + squad-b=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("ambiguous"); }); it("errors when the combined label is not a multi-team section", () => { - const groups = [makeGroup("org/a", ["gamme-client", "squad-a"])]; - const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const groups = [makeGroup("org/a", ["tribe-a", "squad-a"])]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); // Explicit path pointing at a genuine (non-combined) section. - const result = resolvePickTeamAssignment(tree, "gamme-client > squad-a=squad-a"); + const result = resolvePickTeamAssignment(tree, "tribe-a > squad-a=squad-a"); expect("error" in result).toBe(true); expect((result as { error: string }).error).toContain("not a multi-team section"); }); diff --git a/src/group.ts b/src/group.ts index 8d4db23..2955d35 100644 --- a/src/group.ts +++ b/src/group.ts @@ -114,16 +114,16 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti /** * Groups `RepoGroup[]` into a *tree* of `TeamSection`s from one or more * independent prefix chains. Each chain is an ordered list of prefixes, one - * per nesting depth: `["gamme-", "squad-"]` groups repos by teams matching - * `gamme-` first, then sub-groups each resulting section by teams matching + * per nesting depth: `["tribe-", "squad-"]` groups repos by teams matching + * `tribe-` first, then sub-groups each resulting section by teams matching * `squad-`. Multiple chains are processed independently and sequentially * (like `groupByTeamPrefix`'s multi-prefix list), each drawing from the pool * of repos not yet claimed by an earlier chain. * * On top of the explicit chain depth, this also auto-nests sections whose * single-team label is a prefix of another single-team label at the same - * depth (e.g. `gamme-lead-client` becomes the parent of - * `gamme-lead-client-p1`) instead of listing them as unrelated siblings. + * depth (e.g. `tribe-a` becomes the parent of + * `tribe-a-p1`) instead of listing them as unrelated siblings. * Combined-label sections (`"a + b"`) and `"other"` sections are never * auto-nested. * @@ -186,8 +186,8 @@ function applyChainDepth(node: TeamSection, chain: string[], depth: number): Tea /** * Nests sections whose single-team `label` is a proper prefix of another - * single-team label at the same `level` (e.g. `gamme-lead-client` becomes the - * parent of `gamme-lead-client-p1`), instead of leaving them as siblings. + * single-team label at the same `level` (e.g. `tribe-a` becomes the + * parent of `tribe-a-p1`), instead of leaving them as siblings. * Combined-label (`"a + b"`) and `"other"` sections are left untouched at * `level` and passed through unnested. When a chain of overlaps exists * (A prefix of B prefix of C), nesting cascades and `level` is incremented @@ -554,7 +554,7 @@ function stripPickedFrom(g: RepoGroup): RepoGroup { /** * Tree-aware equivalent of `applyTeamPick`: reassigns the ENTIRE subtree of * the combined section identified by `combinedPath` (e.g. - * `["gamme-client", "squad-a + squad-b"]`) — its own `groups` *and* any + * `["tribe-a", "squad-a + squad-b"]`) — its own `groups` *and* any * nested `children` (e.g. it was already subdivided by a further chain * level) — to a sibling section named `chosenTeam` at that same depth * (merged into it if it already exists, otherwise created in its place). @@ -720,8 +720,8 @@ export function findCombinedSectionPaths(sections: TeamSection[]): string[][] { /** * Auto-resolves every combined (`"a + b"`) section whose candidate team names * share a single common-prefix "parent" — one team name that is a literal - * string-prefix of every other team name in the combo (e.g. `"gamme-lead- - * client"` for `"gamme-lead-client + gamme-lead-client-p1"`) — applying the + * string-prefix of every other team name in the combo (e.g. `"tribe-lead- + * client"` for `"tribe-a + tribe-a-p1"`) — applying the * same tree update as an explicit `--pick-team` assignment. Combined sections * with no such prefix relationship (e.g. `"squad-frontend + squad-mobile"`) * are left combined and unresolved, same as today. @@ -771,8 +771,8 @@ function pathExistsInTree(sections: TeamSection[], path: string[]): boolean { /** * Parses the `--group-by-team-prefix` value into one or more prefix chains * for `groupByTeamHierarchy`: `,` separates independent chains, `/` separates - * nesting levels within one chain. E.g. `"gamme-/squad-,chapter-"` produces - * `[["gamme-", "squad-"], ["chapter-"]]`. + * nesting levels within one chain. E.g. `"tribe-/squad-,chapter-"` produces + * `[["tribe-", "squad-"], ["chapter-"]]`. * * Malformed segments (empty chain from a stray/leading/trailing/double `,`, * or an empty level from a stray `/`) are dropped rather than propagated as @@ -821,7 +821,7 @@ export interface ResolvedPickTeam { * `findCombinedSectionPaths` — succeeds only when exactly one match * exists anywhere in the tree; * - a fully-qualified path joined with `" > "` (e.g. - * `"gamme-client > squad-a + squad-b"`), used as-is without validating + * `"tribe-a > squad-a + squad-b"`), used as-is without validating * against `findCombinedSectionPaths` (so it still resolves correctly * right after an earlier assignment already changed the tree shape). * diff --git a/src/output.test.ts b/src/output.test.ts index ad1c07b..894dd0f 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -216,7 +216,7 @@ describe("buildReplayCommand", () => { it("includes --pick-team-auto when pickTeamAuto is true", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; - const opts: ReplayOptions = { groupByTeamPrefix: "gamme-/squad-", pickTeamAuto: true }; + const opts: ReplayOptions = { groupByTeamPrefix: "tribe-/squad-", pickTeamAuto: true }; const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); expect(cmd).toContain("--pick-team-auto"); }); @@ -512,14 +512,14 @@ describe("buildMarkdownOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); - expect(out).toContain("### squad-dashboard"); + expect(out).toContain("## tribe-a"); + expect(out).toContain("### squad-a"); }); it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { @@ -527,13 +527,13 @@ describe("buildMarkdownOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }, { ...makeGroup("myorg/repoB", ["b.ts"]), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }, ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); @@ -557,12 +557,12 @@ describe("buildMarkdownOutput", () => { const groups: RepoGroup[] = [ { ...makeGroup("myorg/repoA", ["a.ts"], { repoSelected: false }), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, makeGroup("myorg/repoB", ["b.ts"]), // same leaf, no sectionPath of its own ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); + expect(out).toContain("## tribe-a"); expect(out).toContain("myorg/repoB"); }); @@ -570,12 +570,12 @@ describe("buildMarkdownOutput", () => { const groups: RepoGroup[] = [ { ...makeGroup("myorg/repoA", ["a.ts"], { extractSelected: [false] }), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, makeGroup("myorg/repoB", ["b.ts"]), ]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); - expect(out).toContain("## gamme-client"); + expect(out).toContain("## tribe-a"); }); it("does not lose a flat sectionLabel heading when its bearing repo is deselected", () => { @@ -653,13 +653,13 @@ describe("buildJsonOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); - expect(parsed.results[0].section).toEqual(["gamme-client", "squad-dashboard"]); + expect(parsed.results[0].section).toEqual(["tribe-a", "squad-a"]); }); it("carries the reconstructed path forward to a sibling repo missing the shared ancestor", () => { @@ -667,18 +667,18 @@ describe("buildJsonOutput", () => { { ...makeGroup("myorg/repoA", ["a.ts"]), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }, { ...makeGroup("myorg/repoB", ["b.ts"]), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }, ]; const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); - expect(parsed.results[0].section).toEqual(["gamme-client", "squad-billing"]); - expect(parsed.results[1].section).toEqual(["gamme-client", "squad-dashboard"]); + expect(parsed.results[0].section).toEqual(["tribe-a", "squad-b"]); + expect(parsed.results[1].section).toEqual(["tribe-a", "squad-a"]); }); it("omits the section field when no sectionPath/sectionLabel is present", () => { @@ -876,7 +876,7 @@ describe("buildOutput", () => { it("threads pickTeamAuto into the replay command", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildOutput(groups, QUERY, ORG, new Set(), new Set(), "json", "repo-and-matches", { - groupByTeamPrefix: "gamme-/squad-", + groupByTeamPrefix: "tribe-/squad-", pickTeamAuto: true, }); const parsed = JSON.parse(out); diff --git a/src/render.test.ts b/src/render.test.ts index c184415..fb5db95 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -403,20 +403,20 @@ describe("buildRows", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }; const rows = buildRows([g1]); expect(rows).toHaveLength(3); // 2 section rows + 1 repo row expect(rows[0]).toMatchObject({ type: "section", - sectionLabel: "gamme-client", + sectionLabel: "tribe-a", sectionLevel: 0, }); expect(rows[1]).toMatchObject({ type: "section", - sectionLabel: "squad-dashboard", + sectionLabel: "squad-a", sectionLevel: 1, }); expect(rows[2]).toMatchObject({ type: "repo", repoIndex: 0 }); @@ -426,34 +426,34 @@ describe("buildRows", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-billing", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-b", level: 1 }, ], }; const g2 = { ...makeGroup("org/repoB", ["b.ts"], true), - sectionPath: [{ label: "squad-dashboard", level: 1 }], + sectionPath: [{ label: "squad-a", level: 1 }], }; const rows = buildRows([g1, g2]); const sectionRows = rows.filter((r) => r.type === "section"); expect(sectionRows.map((r) => `${r.sectionLevel}:${r.sectionLabel}`)).toEqual([ - "0:gamme-client", - "1:squad-billing", - "1:squad-dashboard", + "0:tribe-a", + "1:squad-b", + "1:squad-a", ]); }); it("keeps a pending hierarchical heading across a filtered-out first repo", () => { const g1 = { ...makeGroup("org/repoA", ["a.ts"], true), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }; const g2 = makeGroup("org/repoB", ["b.ts"], true); // same leaf, filtered out below // Filter by path so that repoA (path "a.ts") is hidden but repoB is not. const rows = buildRows([g1, g2], "b.ts", "path", false); expect(rows[0]).toMatchObject({ type: "section", - sectionLabel: "gamme-client", + sectionLabel: "tribe-a", sectionLevel: 0, }); expect(rows[1]).toMatchObject({ type: "repo", repoIndex: 1 }); @@ -2063,14 +2063,14 @@ describe("renderGroups — hierarchical section headings (sectionLevel)", () => const groups = [ { ...makeGroup("org/repoA", ["a.ts"], true), - sectionPath: [{ label: "gamme-client", level: 0 }], + sectionPath: [{ label: "tribe-a", level: 0 }], }, ]; const rows = buildRows(groups); const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); - expect(stripped).toContain("── gamme-client"); - expect(stripped).not.toContain(" ── gamme-client"); + expect(stripped).toContain("── tribe-a"); + expect(stripped).not.toContain(" ── tribe-a"); }); it("indents a level-1 heading by 2 spaces relative to the dashes", () => { @@ -2078,15 +2078,15 @@ describe("renderGroups — hierarchical section headings (sectionLevel)", () => { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, - { label: "squad-dashboard", level: 1 }, + { label: "tribe-a", level: 0 }, + { label: "squad-a", level: 1 }, ], }, ]; const rows = buildRows(groups); const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); - expect(stripped).toContain(" ── squad-dashboard"); + expect(stripped).toContain(" ── squad-a"); }); it("increases indentation progressively for each nesting level", () => { @@ -2137,7 +2137,7 @@ describe("renderGroups — team pick mode section bar", () => { { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-client", level: 0 }, + { label: "tribe-a", level: 0 }, { label: "squad-a + squad-b", level: 1 }, ], }, @@ -2148,7 +2148,7 @@ describe("renderGroups — team pick mode section bar", () => { teamPickMode: { active: true, sectionLabel: "squad-a + squad-b", - sectionPath: ["gamme-client", "squad-a + squad-b"], + sectionPath: ["tribe-a", "squad-a + squad-b"], candidates: ["squad-a", "squad-b"], focusedIndex: 0, }, @@ -2162,7 +2162,7 @@ describe("renderGroups — team pick mode section bar", () => { { ...makeGroup("org/repoA", ["a.ts"], true), sectionPath: [ - { label: "gamme-other", level: 0 }, + { label: "tribe-other", level: 0 }, { label: "squad-a + squad-b", level: 1 }, ], }, @@ -2174,7 +2174,7 @@ describe("renderGroups — team pick mode section bar", () => { active: true, sectionLabel: "squad-a + squad-b", // Targets a DIFFERENT parent than the one actually rendered above. - sectionPath: ["gamme-client", "squad-a + squad-b"], + sectionPath: ["tribe-a", "squad-a + squad-b"], candidates: ["squad-a", "squad-b"], focusedIndex: 0, }, From 228b1a66f5574dd4c4520c54896f1d573ed7de5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 1 Sep 2026 08:56:26 +0200 Subject: [PATCH 3/5] Fix 3 team-grouping bugs: chain fallback, overlap combining, redundant sub-teams - groupByTeamHierarchy now tries every level of a chain in order against what earlier levels of that same chain haven't claimed, so a repo matching only squad- (not tribe-) in a tribe-/squad- chain is captured directly instead of falling through to a later chain or 'other'. - Overlapping single-team labels (e.g. tribe-a / tribe-a-p1) are now combined into one section ("tribe-a + tribe-a-p1"), like a multi-team combo, instead of nested into an extra heading level the declared chain didn't ask for. --pick-team / --pick-team-auto resolve it the same way as any other combo. - bucketSingleLevel now drops a team that is a proper prefix-extension of another team already matched by the same repo (e.g. chapter-architect-a when chapter-architect is also present), reducing unwieldy N-way combos down to their genuinely distinct members before the label is even formed. Removed the now-dead nestOverlappingLabels/assignLevels in favor of a flat, union-find-based combineOverlappingLabels. Updated/added group.ts tests to reproduce all 3 reported bugs and confirm the fixes, and updated docs/usage/team-grouping.md accordingly. --- docs/usage/team-grouping.md | 15 ++- github-code-search.ts | 2 +- src/group.test.ts | 259 ++++++++++++++++++++---------------- src/group.ts | 151 ++++++++++++--------- 4 files changed, 241 insertions(+), 186 deletions(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index bc0a693..c40f954 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -1,6 +1,6 @@ # Team grouping -`--group-by-team-prefix` organises result repositories by their GitHub team membership, as a **hierarchy** of headings. It is especially useful in large organisations with multiple gammes, chapters or squads. +`--group-by-team-prefix` organises result repositories by their GitHub team membership, as a **hierarchy** of headings. It is especially useful in large organisations with multiple tribes, chapters or squads. ## Prerequisites @@ -47,20 +47,23 @@ Within **one level** of a chain, repos are bucketed exactly the same way regardl 3. Repos belonging to **3+** matching teams → same, in ascending combination-size order. 4. Repos matching **no team** at this level → collected into an `other` section. +Before bucketing, a team that is a prefix of another team **already matched by the same repo** is dropped from that repo's matching set (e.g. `chapter-architect-a` is redundant when `chapter-architect` is also present) — the broader team already implies the narrower one, so keeping both would only inflate the combined-section label. + Then, for a chain with more levels, **every section produced above is recursively sub-grouped** by the next prefix — including its own `other` bucket, which becomes a nested `other` at the next depth. +Every level of a chain is tried in order against whatever repos the _earlier levels of that same chain_ haven't already claimed — a repo that only matches `squad-` (not `tribe-`) in a `tribe-/squad-` chain still gets its own top-level section from `squad-`, instead of being invisible to the chain and falling through to a later chain or `other`. + Independent chains (separated by `,`) are processed in order, each consuming repos from the pool not yet claimed by an earlier chain. Repos matched by no chain at all end up in a single top-level `other` section. -### Automatic nesting of overlapping team names +### Automatic combining of overlapping team names -Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool nests the more specific team under the more general one automatically — instead of listing them as unrelated siblings: +Within one level, if a team's name is a **prefix of another team's name** (e.g. `tribe-a` and `tribe-a-p1`), the tool combines them into one section automatically — instead of listing them as unrelated siblings or nesting one under the other: ```text -## tribe-a -### tribe-a-p1 +## tribe-a + tribe-a-p1 ``` -This cascades across any number of overlapping names, and applies independently at every depth of a chain. +This cascades across any number of overlapping names (all merging into one section), and applies independently at every depth of a chain. The combined section behaves exactly like a multi-team combo — `--pick-team` and [`--pick-team-auto`](#auto-pick-by-common-prefix) can resolve it the same way. ## Non-interactive output diff --git a/github-code-search.ts b/github-code-search.ts index 163bfef..ba22b6c 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -186,7 +186,7 @@ function addSearchOptions(cmd: Command): Command { "tribe-/squad-,chapter-", "Repos are first grouped by single-team match, then multi-team, then the next", "level. Repos matching no prefix go into 'other'. Team names that overlap", - "(e.g. squad-a and squad-a-legacy) are nested automatically.", + "(e.g. squad-a and squad-a-legacy) are combined automatically.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping", ].join("\n"), "", diff --git a/src/group.test.ts b/src/group.test.ts index ddf2f21..1ad6bf7 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -46,7 +46,7 @@ describe("excludeTeamsByPrefix", () => { it("supports multiple exclude prefixes", () => { const groups = [ - makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-aix"]), + makeGroup("org/a", ["chapter-secops", "chapter-validators-core", "chapter-architect-a"]), ]; const result = excludeTeamsByPrefix(groups, ["chapter-validators-", "chapter-architect-"]); expect(result[0].teams).toEqual(["chapter-secops"]); @@ -156,6 +156,33 @@ describe("groupByTeamPrefix — multi-team repos", () => { expect(sections).toHaveLength(1); expect(sections[0].groups).toHaveLength(2); }); + + it("fix (#issue: chapter-architect mega-combo): drops redundant sub-teams from a matching-team set before forming the combo label", () => { + // Reported behaviour: a repo tagged with a broad team (chapter-architect) + // AND several of its own narrower variants (chapter-architect-a, + // chapter-architect-b, ...) used to form an unwieldy N-way combined + // section listing every variant. Since the broader team already implies + // membership in each narrower one for grouping purposes, the narrower + // ones are now dropped from the matching set, leaving only the genuinely + // unrelated team (chapter-head-of-frontend) alongside the broad one. + const groups = [ + makeGroup("org/a", [ + "chapter-architect", + "chapter-architect-a", + "chapter-architect-b", + "chapter-architect-c", + "chapter-architect-d", + "chapter-architect-mobile", + "chapter-architect-nodejs", + "chapter-architect-php", + "chapter-architect-python", + "chapter-head-of-frontend", + ]), + ]; + const sections = groupByTeamPrefix(groups, ["chapter-"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("chapter-architect + chapter-head-of-frontend"); + }); }); describe("groupByTeamPrefix — multiple prefixes", () => { @@ -213,17 +240,6 @@ describe("groupByTeamPrefix — multiple prefixes", () => { // ─── groupByTeamHierarchy ───────────────────────────────────────────────────── -/** Flattens a tree's labels (with indent per level) into a single array for - * easy assertions, depth-first, in the order sections are emitted. */ -function collectLabels(sections: TeamSection[]): string[] { - const out: string[] = []; - for (const s of sections) { - out.push(`${" ".repeat(s.level ?? 0)}${s.label}`); - if (s.children) out.push(...collectLabels(s.children)); - } - return out; -} - describe("groupByTeamHierarchy — single-level chain (parity with groupByTeamPrefix)", () => { it("behaves like groupByTeamPrefix for a single 1-level chain", () => { const groups = [makeGroup("org/a", ["squad-frontend"]), makeGroup("org/b", ["squad-mobile"])]; @@ -302,17 +318,15 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { expect(sections[2].groups[0].repoFullName).toBe("org/c"); }); - it("investigation (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) with no 1st-level (tribe-) match is invisible to that chain and falls through to a later chain", () => { + it("fix (#issue: tribe-/squad-,chapter- precedence): a repo matching ONLY a 2nd-level prefix (squad-) is claimed by that level as a fallback, not left invisible to the chain", () => { // Reported behaviour: `fulll/demat-workers` (team squad-c, no tribe- - // team) ends up under a `chapter-` combined section instead of under - // `tribe-/squad-` as its `--group-by-team-prefix tribe-/squad-,chapter-` - // position would suggest. Root cause: a chain only claims a repo if it - // matches chain[0] (tribe-) FIRST — squad- is only ever applied to repos - // already captured under a tribe- section. A repo with a squad- team but - // no tribe- team is entirely invisible to the tribe-/squad- chain and - // falls through to the next chain (or "other") instead — chain - // precedence, as currently implemented, is NOT "any prefix anywhere in - // the chain", only "chain[0], in order". + // team) used to end up under a `chapter-` combined section instead of + // under `tribe-/squad-` as its `--group-by-team-prefix + // tribe-/squad-,chapter-` position would suggest — chain[0] (tribe-) was + // a hard requirement before squad- was even tried. Every level of a + // chain is now tried in order against what earlier levels of *that same + // chain* haven't claimed, so a squad--only repo is captured directly by + // that chain instead of falling through to a later chain or "other". const groups = [ makeGroup("org/demat-workers", [ "squad-c", @@ -321,47 +335,57 @@ describe("groupByTeamHierarchy — multiple independent chains", () => { ]), ]; const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"], ["chapter-"]]); - expect(sections.map((s) => s.label)).not.toContain("tribe-"); - const chapterCombo = sections.find((s) => s.label.includes("chapter-validators")); - expect(chapterCombo).toBeDefined(); - expect(chapterCombo!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); + const squadC = sections.find((s) => s.label === "squad-c"); + expect(squadC).toBeDefined(); + expect(squadC!.level).toBe(0); + expect(squadC!.groups.map((g) => g.repoFullName)).toEqual(["org/demat-workers"]); + expect(sections.map((s) => s.label)).not.toContain("other"); + expect(sections.some((s) => s.label.includes("chapter-validators"))).toBe(false); }); }); -describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () => { - it("nests a longer team name under a shorter one that is its prefix", () => { +describe("groupByTeamHierarchy — combining overlapping team names", () => { + it("combines a longer team name with a shorter one that is its prefix into one section", () => { const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("tribe-a"); + expect(sections[0].label).toBe("tribe-a + tribe-a-p1"); expect(sections[0].level).toBe(0); - expect(sections[0].children).toHaveLength(1); - expect(sections[0].children![0].label).toBe("tribe-a-p1"); - expect(sections[0].children![0].level).toBe(1); + expect(sections[0].children).toBeUndefined(); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); - it("cascades nesting across 3 overlapping names", () => { + it("combines a cascading chain of 3 overlapping names into one section", () => { const groups = [ makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"]), makeGroup("org/c", ["tribe-a-p1-x"]), ]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); - expect(collectLabels(sections)).toEqual(["tribe-a", " tribe-a-p1", " tribe-a-p1-x"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("tribe-a + tribe-a-p1 + tribe-a-p1-x"); + expect(sections[0].groups.map((g) => g.repoFullName).toSorted()).toEqual([ + "org/a", + "org/b", + "org/c", + ]); }); - it("does not nest unrelated single-team labels as siblings", () => { + it("does not combine unrelated single-team labels", () => { const groups = [makeGroup("org/a", ["squad-front"]), makeGroup("org/b", ["squad-back"])]; const sections = groupByTeamHierarchy(groups, [["squad-"]]); - expect(sections.every((s) => !s.children || s.children.length === 0)).toBe(true); + const labels = sections.map((s) => s.label); + expect(labels).toContain("squad-front"); + expect(labels).toContain("squad-back"); }); - it("does not nest combined ('a + b') or 'other' sections", () => { + it("does not combine already-combined ('a + b') or 'other' sections into the overlap cluster", () => { const groups = [makeGroup("org/a", ["squad-front", "squad-back"]), makeGroup("org/b", [])]; const sections = groupByTeamHierarchy(groups, [["squad-"]]); - const combined = sections.find((s) => s.label.includes(" + ")); + const combined = sections.find((s) => s.label === "squad-back + squad-front"); expect(combined).toBeDefined(); - expect(combined!.children ?? []).toHaveLength(0); + const other = sections.find((s) => s.label === "other"); + expect(other).toBeDefined(); }); it("omits the children field entirely on leaf sections instead of an empty array", () => { @@ -370,14 +394,7 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = expect(sections[0].children).toBeUndefined(); }); - it("keeps a parent's own groups when it also has an overlap-nested child", () => { - const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; - const sections = groupByTeamHierarchy(groups, [["tribe-"]]); - expect(sections[0].groups.map((g) => g.repoFullName)).toEqual(["org/a"]); - expect(sections[0].children).toHaveLength(1); - }); - - it("splits a parent's own groups by the next chain level even when it also has an overlap-nested child", () => { + it("splits a combined overlap section by the next chain level", () => { const groups = [ makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1", "squad-mobile"]), @@ -386,41 +403,56 @@ describe("groupByTeamHierarchy — auto-nesting of overlapping team names", () = const sections = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); expect(sections).toHaveLength(1); const parent = sections[0]; - expect(parent.label).toBe("tribe-a"); - // Fully subdivided — none of its own repos are left flat on the parent. + expect(parent.label).toBe("tribe-a + tribe-a-p1"); + // Fully subdivided by the squad- level — none of its own repos are left flat. expect(parent.groups).toEqual([]); const childLabels = (parent.children ?? []).map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["other", "squad-b", "tribe-a-p1"]); - const squadBilling = parent.children!.find((c) => c.label === "squad-b")!; - expect(squadBilling.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + expect(childLabels).toEqual(["other", "squad-b", "squad-mobile"]); + const squadB = parent.children!.find((c) => c.label === "squad-b")!; + expect(squadB.groups.map((g) => g.repoFullName)).toEqual(["org/c"]); + const squadMobile = parent.children!.find((c) => c.label === "squad-mobile")!; + expect(squadMobile.groups.map((g) => g.repoFullName)).toEqual(["org/b"]); const other = parent.children!.find((c) => c.label === "other")!; expect(other.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); - // The overlap-nested child was ALSO subdivided by the next chain level. - const p1 = parent.children!.find((c) => c.label === "tribe-a-p1")!; - expect(p1.children).toHaveLength(1); - expect(p1.children![0].label).toBe("squad-mobile"); - expect(p1.children![0].groups.map((g) => g.repoFullName)).toEqual(["org/b"]); }); - it("investigation (#issue: tribe-/squad- + overlapping name): auto-nesting inserts an EXTRA heading level beyond the declared chain depth", () => { + it("fix (#issue: tribe-/squad- + overlapping name): combining no longer inserts an EXTRA heading level beyond the declared chain depth", () => { // Reported behaviour: `--group-by-team-prefix tribe-/squad-` (a 2-level - // chain) renders 3 heading levels — `tribe-a` / - // `tribe-a-security-p1` / `squad-d` — instead of the - // 2 the chain declares. This documents that this is the INTENDED result - // of combining two separate, both-documented mechanisms: automatic - // overlap-nesting of single-team labels (this describe block) PLUS the - // chain's own `/squad-` depth — not a bug in either mechanism alone. + // chain) used to render 3 heading levels — `tribe-a` / + // `tribe-a-security-p1` / `squad-d` — instead of the 2 the chain + // declares. Overlapping single-team labels are now combined into one + // section instead of nested, so the chain's own `/squad-` depth is the + // only extra level that can appear. const groups = [ makeGroup("org/parent-only", ["tribe-a"]), makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); - expect(collectLabels(tree)).toEqual([ - "tribe-a", - " tribe-a-security-p1", - " squad-d", - " other", - ]); + expect(tree).toHaveLength(1); + expect(tree[0].label).toBe("tribe-a + tribe-a-security-p1"); + expect(tree[0].level).toBe(0); + const childLabels = (tree[0].children ?? []).map((c) => c.label).toSorted(); + expect(childLabels).toEqual(["other", "squad-d"]); + const squadD = tree[0].children!.find((c) => c.label === "squad-d")!; + expect(squadD.level).toBe(1); + expect(squadD.groups.map((g) => g.repoFullName)).toEqual(["org/leaf"]); + const other = tree[0].children!.find((c) => c.label === "other")!; + expect(other.groups.map((g) => g.repoFullName)).toEqual(["org/parent-only"]); + }); + + it("fix (#issue: tribe-/squad- + overlapping name): --pick-team-auto fully resolves the combined section to a single heading", () => { + // End-to-end confirmation: combining (this describe block) plus + // --pick-team-auto together produce exactly "## tribe-a", with no + // "## tribe-a-security-p1" heading at any level, as requested. + const groups = [ + makeGroup("org/parent-only", ["tribe-a"]), + makeGroup("org/leaf", ["tribe-a-security-p1", "squad-d"]), + ]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(resolved).toHaveLength(1); + expect(resolved[0].label).toBe("tribe-a"); + expect(findCombinedSectionPaths(resolved)).toEqual([]); }); }); @@ -468,15 +500,15 @@ describe("flattenTeamHierarchy", () => { expect(flat[1].sectionPath).toEqual([{ label: "chapter-backend", level: 0 }]); }); - it("includes a parent's own repos even when it also has nested overlap children", () => { - // "tribe-a" owns org/a directly AND has an overlap-nested - // child "tribe-a-p1" owning org/b — both must appear. + it("flattens a combined overlap section as a single heading for both constituent repos", () => { + // "tribe-a" and "tribe-a-p1" are combined into one "tribe-a + tribe-a-p1" + // section (see combining tests) — both repos share that single heading. const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const sections = groupByTeamHierarchy(groups, [["tribe-"]]); const flat = flattenTeamHierarchy(sections); expect(flat.map((g) => g.repoFullName)).toEqual(["org/a", "org/b"]); - expect(flat[0].sectionPath).toEqual([{ label: "tribe-a", level: 0 }]); - expect(flat[1].sectionPath).toEqual([{ label: "tribe-a-p1", level: 1 }]); + expect(flat[0].sectionPath).toEqual([{ label: "tribe-a + tribe-a-p1", level: 0 }]); + expect(flat[1].sectionPath).toBeUndefined(); }); it("does not mutate the input tree", () => { @@ -587,23 +619,20 @@ describe("applyTeamPickInTree", () => { }); it("preserves the picked section's own children (does not drop the subtree)", () => { - // Regression: a top-level combined section ("tribe-a + tribe-a-security-p1") - // that was already subdivided by the next chain level (squad-) must keep - // its nested children when picked — only its own (now empty) `groups` - // were carried over before the fix, silently dropping every repo nested - // underneath. + // Regression: a top-level combined section ("tribe-b + tribe-b-p1") that + // was already subdivided by the next chain level (squad-) must keep its + // nested children when picked — only its own (now empty) `groups` were + // carried over before the fix, silently dropping every repo nested + // underneath. Each repo has only ONE tribe- team (never both), so the + // combined section is formed by combineOverlappingLabels across the two + // repos, not collapsed away by the redundant-sub-team reduction. const groups = [ - makeGroup("org/tools-mobile", [ - "tribe-b", - "tribe-b-security-p1", - "squad-core", - "squad-mobile", - ]), - makeGroup("org/wizard-mobile", ["tribe-b", "tribe-b-security-p1"]), + makeGroup("org/a", ["tribe-b", "squad-core", "squad-mobile"]), + makeGroup("org/b", ["tribe-b-p1"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const combined = tree.find((s) => s.label.includes(" + "))!; - expect(combined.label).toBe("tribe-b + tribe-b-security-p1"); + expect(combined.label).toBe("tribe-b + tribe-b-p1"); expect(combined.groups).toEqual([]); // fully subdivided by squad- before the pick expect(combined.children).toHaveLength(2); // "squad-core + squad-mobile" and "other" @@ -614,27 +643,14 @@ describe("applyTeamPickInTree", () => { expect(picked).toBeDefined(); expect(picked.children).toHaveLength(2); const squadChild = picked.children!.find((c) => c.label === "squad-core + squad-mobile")!; - expect(squadChild.groups.map((g) => g.repoFullName)).toEqual(["org/tools-mobile"]); + expect(squadChild.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); const otherChild = picked.children!.find((c) => c.label === "other")!; - expect(otherChild.groups.map((g) => g.repoFullName)).toEqual(["org/wizard-mobile"]); + expect(otherChild.groups.map((g) => g.repoFullName)).toEqual(["org/b"]); // Every repo in the moved subtree is tagged, not just the top node's own groups. expect(squadChild.groups[0].pickedFrom).toBe(combined.label); expect(otherChild.groups[0].pickedFrom).toBe(combined.label); }); - it("merges the picked subtree's children into an existing target section's children", () => { - const groups = [ - makeGroup("org/existing", ["tribe-b", "squad-existing"]), - makeGroup("org/tools-mobile", ["tribe-b", "tribe-b-security-p1", "squad-core"]), - ]; - const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); - const combined = tree.find((s) => s.label.includes(" + "))!; - const updated = applyTeamPickInTree(tree, [combined.label], "tribe-b"); - const picked = updated.find((s) => s.label === "tribe-b")!; - const childLabels = picked.children!.map((c) => c.label).toSorted(); - expect(childLabels).toEqual(["squad-core", "squad-existing"]); - }); - it("returns sections unchanged for an empty combinedPath", () => { const groups = [makeGroup("org/a")]; const tree = groupByTeamHierarchy(groups, [["squad-"]]); @@ -803,14 +819,14 @@ describe("findCombinedSectionPaths", () => { describe("autoPickTeamsByCommonPrefix", () => { it("resolves a combined section to the team that is a prefix of the other", () => { - const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); expect(findCombinedSectionPaths(tree)).toEqual([["tribe-a + tribe-a-p1"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); const winner = resolved.find((s) => s.label === "tribe-a")!; - expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(winner.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); it("leaves a combined section unresolved when no team is a prefix of the others", () => { @@ -821,7 +837,11 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("picks the shortest common-prefix team among 3+ combined teams", () => { - const groups = [makeGroup("org/a", ["tribe-x", "tribe-x-y", "tribe-x-y-z"])]; + const groups = [ + makeGroup("org/a", ["tribe-x"]), + makeGroup("org/b", ["tribe-x-y"]), + makeGroup("org/c", ["tribe-x-y-z"]), + ]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); @@ -830,8 +850,8 @@ describe("autoPickTeamsByCommonPrefix", () => { it("resolves independently at a nested (non-top-level) depth", () => { const groups = [ - makeGroup("org/a", ["tribe-a", "squad-a", "squad-a-legacy"]), - makeGroup("org/b", ["tribe-a"]), + makeGroup("org/a", ["tribe-a", "squad-a"]), + makeGroup("org/b", ["tribe-a", "squad-a-legacy"]), ]; const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); @@ -839,7 +859,7 @@ describe("autoPickTeamsByCommonPrefix", () => { const gamme = resolved.find((s) => s.label === "tribe-a")!; const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; expect(child).toBeDefined(); - expect(child.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + expect(child.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); it("an explicit --pick-team resolution is left untouched (no longer combined) when auto-pick runs after", () => { @@ -851,7 +871,7 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("is a pure function — does not mutate the input tree", () => { - const groups = [makeGroup("org/a", ["tribe-a", "tribe-a-p1"])]; + const groups = [makeGroup("org/a", ["tribe-a"]), makeGroup("org/b", ["tribe-a-p1"])]; const tree = groupByTeamHierarchy(groups, [["tribe-"]]); const before = JSON.stringify(tree); autoPickTeamsByCommonPrefix(tree); @@ -868,13 +888,16 @@ describe("autoPickTeamsByCommonPrefix", () => { // Reported expectation: "chapter-secops + chapter-validators-core", // "chapter-head-of-frontend + chapter-secops + chapter-validators-core" // and "chapter-secops + chapter-validators + chapter-validators-core" - // should all collapse under "chapter-secops". None of the 4 distinct - // team names here is a literal string-prefix of the others (secops vs - // validators-core vs validators vs head-of-frontend), so the current - // "one candidate is a prefix of all others" strategy correctly leaves - // all 3 combined and unresolved — merging them would require a - // different algorithm (cluster by shared team membership across - // combos), which is out of scope for this strategy. + // should all collapse under "chapter-secops". None of the distinct team + // names here is a literal string-prefix of every other one in its own + // combo (secops vs validators-core vs validators vs head-of-frontend), so + // the "one candidate is a prefix of all others" strategy correctly leaves + // them combined and unresolved — merging them further would require a + // different algorithm (cluster by shared team membership across combos), + // which is out of scope for this strategy. Note org/c's own combo is + // already reduced from 3-way to 2-way by dropRedundantSubTeams, since + // chapter-validators-core is a redundant sub-team of chapter-validators + // (both present on org/c) — a real improvement, just not full merging. const groups = [ makeGroup("org/a", ["chapter-secops", "chapter-validators-core"]), makeGroup("org/b", ["chapter-head-of-frontend", "chapter-secops", "chapter-validators-core"]), @@ -889,7 +912,7 @@ describe("autoPickTeamsByCommonPrefix", () => { ).toEqual( [ "chapter-head-of-frontend + chapter-secops + chapter-validators-core", - "chapter-secops + chapter-validators + chapter-validators-core", + "chapter-secops + chapter-validators", "chapter-secops + chapter-validators-core", ].toSorted(), ); diff --git a/src/group.ts b/src/group.ts index 2955d35..be13f25 100644 --- a/src/group.ts +++ b/src/group.ts @@ -81,7 +81,9 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCount = new Map(); for (const g of matchingGroups) { - const matchingTeams = (g.teams ?? []).filter((t) => t.startsWith(prefix)); + const matchingTeams = dropRedundantSubTeams( + (g.teams ?? []).filter((t) => t.startsWith(prefix)), + ); const count = matchingTeams.length; if (!byCount.has(count)) byCount.set(count, []); byCount.get(count)!.push(g); @@ -93,8 +95,9 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCombo = new Map(); for (const g of groupsInBucket) { - const matchingTeams = (g.teams ?? []) - .filter((t) => t.startsWith(prefix)) + const matchingTeams = dropRedundantSubTeams( + (g.teams ?? []).filter((t) => t.startsWith(prefix)), + ) .toSorted() .join(" + "); if (!byCombo.has(matchingTeams)) byCombo.set(matchingTeams, []); @@ -109,6 +112,18 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti return sections; } +/** + * Drops any team that is a proper prefix-extension of another team already + * present in `teams` (e.g. `chapter-architect-a` is dropped when + * `chapter-architect` is also present in the same repo's matching teams) — + * the broader team already implies the narrower one for grouping purposes, + * so keeping both only inflates combined-section labels with redundant + * information. Pure — returns a new array. + */ +function dropRedundantSubTeams(teams: string[]): string[] { + return teams.filter((t) => !teams.some((other) => other !== t && t.startsWith(other))); +} + // ─── Hierarchical (nested) team-prefix grouping ─────────────────────────────── /** @@ -120,12 +135,19 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti * (like `groupByTeamPrefix`'s multi-prefix list), each drawing from the pool * of repos not yet claimed by an earlier chain. * - * On top of the explicit chain depth, this also auto-nests sections whose + * Within one chain, every level is tried in order against whatever the + * earlier levels of *that same chain* haven't already claimed: a repo + * matching only `chain[1]` (e.g. `squad-`) and not `chain[0]` (e.g. + * `tribe-`) still gets its own top-level section from `chain[1]`, instead of + * being invisible to the chain and falling through to a later chain or + * `"other"`. + * + * On top of the explicit chain depth, this also combines sections whose * single-team label is a prefix of another single-team label at the same - * depth (e.g. `tribe-a` becomes the parent of - * `tribe-a-p1`) instead of listing them as unrelated siblings. - * Combined-label sections (`"a + b"`) and `"other"` sections are never - * auto-nested. + * depth (e.g. `tribe-a` and `tribe-a-p1`) into one `"tribe-a + tribe-a-p1"` + * section, exactly like a multi-team combo — instead of nesting them into + * extra heading levels the declared chain didn't ask for. Combined-label + * sections (`"a + b"`) and `"other"` sections are left as-is. * * Repos matching no prefix at a given depth are collected into an `"other"` * child at that depth; repos matching no chain at all are collected into a @@ -140,11 +162,13 @@ export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): T for (const chain of chains) { if (chain.length === 0) continue; - const siblings = bucketSingleLevel(remaining, chain[0]).map((s) => ({ ...s, level: 0 })); - if (siblings.length === 0) continue; + for (let i = 0; i < chain.length; i++) { + const siblings = bucketSingleLevel(remaining, chain[i]).map((s) => ({ ...s, level: 0 })); + if (siblings.length === 0) continue; - const nested = nestOverlappingLabels(siblings, 0); - sections.push(...nested.map((s) => applyChainDepth(s, chain, 1))); + const combined = combineOverlappingLabels(siblings, 0); + sections.push(...combined.map((s) => applyChainDepth(s, chain, i + 1))); + } } if (remaining.size > 0) { @@ -156,18 +180,12 @@ export function groupByTeamHierarchy(groups: RepoGroup[], chains: string[][]): T /** * Recursively subdivides `node` by the next prefix in `chain` (at `depth`). - * Any pre-existing overlap-nested `children` haven't consumed `chain[depth]` - * yet either, so they're recursed into first (at the same `depth`); `node`'s - * own `groups` (repos owned directly by this section, which can coexist with - * overlap children — see `TeamSection`) are then split into *additional* - * children. No-op once `depth` exceeds the chain or there is nothing left to - * split at this node. + * No-op once `depth` exceeds the chain or there is nothing left to split at + * this node. */ function applyChainDepth(node: TeamSection, chain: string[], depth: number): TeamSection { - const recursedChildren = (node.children ?? []).map((c) => applyChainDepth(c, chain, depth)); - if (depth >= chain.length || node.groups.length === 0) { - return recursedChildren.length > 0 ? { ...node, children: recursedChildren } : node; + return node; } const level = (node.level ?? 0) + 1; @@ -177,65 +195,76 @@ function applyChainDepth(node: TeamSection, chain: string[], depth: number): Tea siblings.push({ label: "other", groups: [...localRemaining], level }); } - const splitChildren = nestOverlappingLabels(siblings, level).map((c) => + const children = combineOverlappingLabels(siblings, level).map((c) => applyChainDepth(c, chain, depth + 1), ); - return { ...node, groups: [], children: [...recursedChildren, ...splitChildren] }; + return { ...node, groups: [], children }; } /** - * Nests sections whose single-team `label` is a proper prefix of another - * single-team label at the same `level` (e.g. `tribe-a` becomes the - * parent of `tribe-a-p1`), instead of leaving them as siblings. - * Combined-label (`"a + b"`) and `"other"` sections are left untouched at - * `level` and passed through unnested. When a chain of overlaps exists - * (A prefix of B prefix of C), nesting cascades and `level` is incremented - * once per hop from the shallowest ancestor. + * Combines sections whose single-team `label` is a proper prefix of another + * single-team label at the same `level` (e.g. `tribe-a` and `tribe-a-p1`) + * into one section labelled like a multi-team combo (`"tribe-a + tribe-a-p1"`, + * teams sorted and joined), merging their `groups` — instead of nesting them + * into extra heading levels. This lets `--pick-team` / `--pick-team-auto` + * resolve them exactly like any other combined section. Combined-label + * (`"a + b"`) and `"other"` sections are left untouched. Cascading overlaps + * (A prefix of B prefix of C) merge into a single combined section for the + * whole connected chain, since the string-prefix relation is transitive. + * + * Pure — returns a new flat array, no `children`/nesting introduced here. */ -function nestOverlappingLabels(sections: TeamSection[], level: number): TeamSection[] { +function combineOverlappingLabels(sections: TeamSection[], level: number): TeamSection[] { const nestable = sections.filter((s) => s.label !== "other" && !s.label.includes(" + ")); const rest = sections .filter((s) => s.label === "other" || s.label.includes(" + ")) .map((s) => ({ ...s, level })); - const nodeByLabel = new Map( - nestable.map((s) => [s.label, { ...s, level, children: [] }]), - ); + const parent = new Map(); + const find = (label: string): string => { + let root = label; + while (parent.get(root) !== root) root = parent.get(root)!; + return root; + }; + const union = (a: string, b: string): void => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; - const parentOf = new Map(); - for (const s of nestable) { - let bestParent: string | undefined; - for (const other of nestable) { - if (other.label === s.label) continue; - if ( - s.label.startsWith(other.label) && - (bestParent === undefined || other.label.length > bestParent.length) - ) { - bestParent = other.label; - } + for (const s of nestable) parent.set(s.label, s.label); + for (const a of nestable) { + for (const b of nestable) { + if (a.label !== b.label && a.label.startsWith(b.label)) union(a.label, b.label); } - if (bestParent !== undefined) parentOf.set(s.label, bestParent); } - for (const [child, parent] of parentOf) { - nodeByLabel.get(parent)!.children.push(nodeByLabel.get(child)!); + const clusters = new Map(); + for (const s of nestable) { + const root = find(s.label); + if (!clusters.has(root)) clusters.set(root, []); + clusters.get(root)!.push(s); } - const roots = nestable - .filter((s) => !parentOf.has(s.label)) - .map((s) => assignLevels(nodeByLabel.get(s.label)!, level)); - - return [...roots, ...rest]; -} - -/** Sets `level` on `node` (and cascades +1 per depth into its children), mutating in place. */ -function assignLevels(node: TeamSection, lvl: number): TeamSection { - node.level = lvl; - if (node.children && node.children.length > 0) { - node.children = node.children.map((c) => assignLevels(c, lvl + 1)); + const combined: TeamSection[] = []; + for (const members of clusters.values()) { + if (members.length === 1) { + combined.push({ ...members[0], level }); + continue; + } + combined.push({ + label: members + .map((m) => m.label) + .toSorted() + .join(" + "), + groups: members.flatMap((m) => m.groups), + level, + }); } - return node; + combined.sort((a, b) => a.label.localeCompare(b.label)); + + return [...combined, ...rest]; } /** From 39f498a12de8bdbc9e2991d566104a499c085a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 11:22:40 +0200 Subject: [PATCH 4/5] Fix cross-combo team clustering and path:*.ext wildcard warning - autoPickTeamsByCommonPrefix now clusters combined sections that share a recurring team across siblings under the same parent, even when no candidate is a literal prefix of the others (e.g. chapter-architect + chapter-frontend and chapter-architect + chapter-backend-node both resolve to chapter-architect). - Add detectPathWildcardLimitation(): warns on stderr when a path: qualifier contains a * wildcard, since GitHub's code search API silently ignores it instead of expanding it as a glob (use language:/extension: instead). Documented in docs/usage/search-syntax.md. - Replace remaining generic/gamme leftovers and illustrative fulll/ example names across docs/usage/*.md with fully generic placeholders. --- docs/usage/filtering.md | 16 ++-- docs/usage/interactive-mode.md | 16 ++-- docs/usage/non-interactive-mode.md | 20 ++-- docs/usage/output-formats.md | 22 ++--- docs/usage/search-syntax.md | 39 ++++++-- docs/usage/team-grouping.md | 42 ++++----- github-code-search.ts | 15 ++- src/group.test.ts | 145 ++++++++++++++++++++--------- src/group.ts | 107 ++++++++++++++++++--- src/regex.test.ts | 49 +++++++++- src/regex.ts | 31 ++++++ 11 files changed, 377 insertions(+), 125 deletions(-) diff --git a/docs/usage/filtering.md b/docs/usage/filtering.md index 9fcfd2e..260b5f8 100644 --- a/docs/usage/filtering.md +++ b/docs/usage/filtering.md @@ -9,11 +9,11 @@ Excludes entire repositories from the results. The org prefix is optional. ```bash # Short form (recommended) github-code-search "useFeatureFlag" --org fulll \ - --exclude-repositories legacy-monolith,archived-app + --exclude-repositories legacy-app,archived-app # Long form (also accepted) github-code-search "useFeatureFlag" --org fulll \ - --exclude-repositories fulll/legacy-monolith,fulll/archived-app + --exclude-repositories fulll/legacy-app,fulll/archived-app ``` Pass a comma-separated list. There is no limit on the number of repos you can exclude. @@ -27,17 +27,17 @@ The short form (without the org prefix) is recommended — it is easier to read Excludes individual code extracts. The format is `repoName:path/to/file:matchIndex`. ```bash -# Exclude the first extract (index 0) of src/flags.ts in billing-api +# Exclude the first extract (index 0) of src/flags.ts in service-b github-code-search "useFeatureFlag" --org fulll \ - --exclude-extracts billing-api:src/flags.ts:0 + --exclude-extracts service-b:src/flags.ts:0 # Exclude multiple extracts github-code-search "useFeatureFlag" --org fulll \ - --exclude-extracts billing-api:src/flags.ts:0,auth-service:tests/unit/featureFlags.test.ts:1 + --exclude-extracts service-b:src/flags.ts:0,service-a:tests/unit/featureFlags.test.ts:1 # Long form (also accepted) github-code-search "useFeatureFlag" --org fulll \ - --exclude-extracts fulll/billing-api:src/flags.ts:0 + --exclude-extracts fulll/service-b:src/flags.ts:0 ``` The index is **zero-based** and corresponds to the position of the file in the GitHub API result list for that repository — not the position of the match within the file itself. Each `(repo, file)` pair is one extract with a unique index. @@ -84,8 +84,8 @@ All four flags can be combined freely: github-code-search "useFeatureFlag" --org fulll \ --include-archived \ --exclude-template-repositories \ - --exclude-repositories legacy-monolith \ - --exclude-extracts billing-api:src/flags.ts:0 + --exclude-repositories legacy-app \ + --exclude-extracts service-b:src/flags.ts:0 ``` ## In-TUI filtering diff --git a/docs/usage/interactive-mode.md b/docs/usage/interactive-mode.md index c3906bc..f814102 100644 --- a/docs/usage/interactive-mode.md +++ b/docs/usage/interactive-mode.md @@ -15,13 +15,13 @@ github-code-search "useFeatureFlag" --org fulll 3 repos · 4 files ← / → fold/unfold ↑ / ↓ navigate spc select a all n none f filter h help ↵ confirm q quit -▸ fulll/billing-api 3 matches -▾ ✓ fulll/auth-service 2 matches +▸ fulll/service-b 3 matches +▾ ✓ fulll/service-a 2 matches ✓ src/middlewares/featureFlags.ts …const flag = useFeatureFlag('new-onboarding'); if (!flag) return next();… ✓ tests/unit/featureFlags.test.ts …expect(useFeatureFlag('new-onboarding')).toBe(true);… -▸ fulll/legacy-monolith 1 match +▸ fulll/legacy-app 1 match ``` - `▸` — folded repo (extracts hidden) @@ -167,10 +167,10 @@ After pressing `Enter`: ```text 2 repos · 2 files selected -- **fulll/auth-service** (1 match) - - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2) -- **fulll/billing-api** (1 match) - - [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3) +- **fulll/service-a** (1 match) + - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/service-a/blob/main/src/middlewares/featureFlags.ts#L2) +- **fulll/service-b** (1 match) + - [ ] [src/flags.ts:3:14](https://github.com/fulll/service-b/blob/main/src/flags.ts#L3) ```
@@ -178,7 +178,7 @@ After pressing `Enter`: ```bash github-code-search "useFeatureFlag" --org fulll --no-interactive \ - --exclude-repositories legacy-monolith + --exclude-repositories legacy-app ```
diff --git a/docs/usage/non-interactive-mode.md b/docs/usage/non-interactive-mode.md index 38aeeec..1d5dbce 100644 --- a/docs/usage/non-interactive-mode.md +++ b/docs/usage/non-interactive-mode.md @@ -41,14 +41,14 @@ $ CI=true github-code-search "useFeatureFlag" --org fulll 3 repos · 5 files selected -- **fulll/auth-service** (2 matches) - - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2): `useFeatureFlag` - - [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1): `useFeatureFlag` -- **fulll/billing-api** (2 matches) - - [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3): `useFeatureFlag` - - [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1): `useFeatureFlag` -- **fulll/frontend-app** (1 match) - - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1): `useFeatureFlag` +- **fulll/service-a** (2 matches) + - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/service-a/blob/main/src/middlewares/featureFlags.ts#L2): `useFeatureFlag` + - [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/service-a/blob/main/tests/unit/featureFlags.test.ts#L1): `useFeatureFlag` +- **fulll/service-b** (2 matches) + - [ ] [src/flags.ts:3:14](https://github.com/fulll/service-b/blob/main/src/flags.ts#L3): `useFeatureFlag` + - [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/service-b/blob/main/src/routes/invoices.ts#L1): `useFeatureFlag` +- **fulll/app-a** (1 match) + - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/app-a/blob/main/src/hooks/useFeatureFlag.ts#L1): `useFeatureFlag` ```
@@ -66,8 +66,8 @@ At the end of every interactive session, `github-code-search` prints a **replay ```bash github-code-search "useFeatureFlag" --org fulll --no-interactive \ - --exclude-repositories legacy-monolith \ - --exclude-extracts auth-service:tests/unit/featureFlags.test.ts:0 + --exclude-repositories legacy-app \ + --exclude-extracts service-a:tests/unit/featureFlags.test.ts:0 ``` This is the recommended bridge between an interactive exploration session and a reproducible CI step. diff --git a/docs/usage/output-formats.md b/docs/usage/output-formats.md index 217faf5..be7b890 100644 --- a/docs/usage/output-formats.md +++ b/docs/usage/output-formats.md @@ -15,12 +15,12 @@ github-code-search "useFeatureFlag" --org fulll --format markdown --no-interacti 3 repos · 4 files selected -- **fulll/auth-service** (2 matches) +- **fulll/service-a** (2 matches) - [ ] [src/middlewares/featureFlags.ts:2:19](...): `useFeatureFlag` - [ ] [tests/unit/featureFlags.test.ts:1:8](...): `useFeatureFlag` -- **fulll/billing-api** (1 match) +- **fulll/service-b** (1 match) - [ ] [src/flags.ts:3:14](...): `useFeatureFlag` -- **fulll/frontend-app** (1 match) +- **fulll/app-a** (1 match) - [ ] [src/hooks/useFeatureFlag.ts:1:1](...): `useFeatureFlag` ``` @@ -67,11 +67,11 @@ github-code-search "useFeatureFlag" --org fulll --format json --no-interactive "selection": { "repos": 1, "matches": 1 }, "results": [ { - "repo": "fulll/auth-service", + "repo": "fulll/service-a", "matches": [ { "path": "src/middlewares/featureFlags.ts", - "url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts", + "url": "https://github.com/fulll/service-a/blob/main/src/middlewares/featureFlags.ts", "line": 2, "col": 19, "matchedText": "useFeatureFlag" @@ -106,9 +106,9 @@ github-code-search "useFeatureFlag" --org fulll \ ```text # Results for "useFeatureFlag" -fulll/auth-service -fulll/billing-api -fulll/frontend-app +fulll/service-a +fulll/service-b +fulll/app-a ``` ::: details replay command @@ -132,9 +132,9 @@ github-code-search "useFeatureFlag" --org fulll \ "org": "fulll", "selection": { "repos": 3, "matches": 5 }, "results": [ - { "repo": "fulll/auth-service" }, - { "repo": "fulll/billing-api" }, - { "repo": "fulll/frontend-app" } + { "repo": "fulll/service-a" }, + { "repo": "fulll/service-b" }, + { "repo": "fulll/app-a" } ], "replayCommand": "# Replay:\ngithub-code-search \"useFeatureFlag\" --org fulll --format json --no-interactive --output-type repo-only" } diff --git a/docs/usage/search-syntax.md b/docs/usage/search-syntax.md index 8034118..fd3a317 100644 --- a/docs/usage/search-syntax.md +++ b/docs/usage/search-syntax.md @@ -14,21 +14,40 @@ Searches for the literal string `useFeatureFlag` across all repositories in the GitHub code search supports a set of qualifiers you can combine with your keyword: -| Qualifier | Description | Example | -| --------------------- | ---------------------------------------------------------------- | ------------------------------------ | -| `language:` | Filter by programming language | `useFeatureFlag language:TypeScript` | -| `path:` | Restrict to files whose path matches the glob or substring | `config path:src/config` | -| `filename:` | Match files by name (supports wildcards) | `SECRET filename:.env` | -| `extension:` | Match files by extension | `connect extension:ts` | -| `repo:/` | Restrict to a single repository (less useful here — use `--org`) | `connect repo:fulll/billing-api` | -| `NOT ` | Exclude a keyword | `connect NOT deprecated` | -| `"exact phrase"` | Exact multi-word match | `"feature flag"` | +| Qualifier | Description | Example | +| --------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `language:` | Filter by programming language | `useFeatureFlag language:TypeScript` | +| `path:` | Restrict to files located in a directory (or any of its subdirectories) — **not** a glob/extension filter | `config path:src/config` | +| `filename:` | Match files by name (supports wildcards) | `SECRET filename:.env` | +| `extension:` | Match files by extension | `connect extension:ts` | +| `repo:/` | Restrict to a single repository (less useful here — use `--org`) | `connect repo:fulll/service-b` | +| `NOT ` | Exclude a keyword | `connect NOT deprecated` | +| `"exact phrase"` | Exact multi-word match | `"feature flag"` | ::: tip Qualifiers can be combined freely: `"feature flag" language:TypeScript path:src/` ::: +::: warning `path:` does not support glob wildcards +`path:` matches a **directory location**, not a filename pattern — GitHub's code +search API silently ignores the `*` wildcard character rather than expanding it +as a glob, so a query like `path:*.tf` matches few or no files. To filter by +file type, use `language:` or `extension:` instead: + +```bash +# Wrong — path:*.tf is silently ignored by the GitHub API, returns no results +github-code-search "ACME123456789 path:*.tf" --org fulll + +# Right — filters by language or extension instead +github-code-search "ACME123456789 language:hcl" --org fulll +github-code-search "ACME123456789 extension:tf" --org fulll +``` + +`github-code-search` detects this pattern and prints a warning on stderr when a +`path:` qualifier contains a `*` character. +::: + ## Practical examples ### Find all usages of a function @@ -66,7 +85,7 @@ github-code-search "useFeatureFlag NOT filename:test NOT filename:spec" --org fu Although `--org` already limits the search to your organisation, you can further narrow results to one or more specific repositories using `repo:` qualifiers in the query string: ```bash -github-code-search "useFeatureFlag repo:fulll/billing-api repo:fulll/auth-service" --org fulll +github-code-search "useFeatureFlag repo:fulll/service-b repo:fulll/service-a" --org fulll ``` `--org` is still required for the API call even when `repo:` qualifiers are present. The `org:` qualifier is injected automatically alongside your query. diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index c40f954..a31caf8 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -25,7 +25,7 @@ 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 tribe- first, then by squad- within each gamme +# One 2-level chain: group by tribe- first, then by squad- within each tribe github-code-search "useFeatureFlag" --org fulll \ --group-by-team-prefix tribe-/squad- ``` @@ -74,23 +74,23 @@ This cascades across any number of overlapping names (all merging into one secti ## squad-backend -- **fulll/billing-api** (3 matches) - - [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3) +- **fulll/service-b** (3 matches) + - [ ] [src/flags.ts:3:14](https://github.com/fulll/service-b/blob/main/src/flags.ts#L3) ## squad-frontend -- **fulll/auth-service** (2 matches) - - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2) +- **fulll/service-a** (2 matches) + - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/service-a/blob/main/src/middlewares/featureFlags.ts#L2) ## squad-frontend + squad-mobile -- **fulll/frontend-app** (1 match) - - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1) +- **fulll/app-a** (1 match) + - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/app-a/blob/main/src/hooks/useFeatureFlag.ts#L1) ## other -- **fulll/legacy-monolith** (1 match) - - [ ] [src/legacy.js:5:1](https://github.com/fulll/legacy-monolith/blob/main/src/legacy.js#L5) +- **fulll/legacy-app** (1 match) + - [ ] [src/legacy.js:5:1](https://github.com/fulll/legacy-app/blob/main/src/legacy.js#L5) ``` ### Nested (`tribe-/squad-`) output @@ -103,19 +103,19 @@ Nested levels render as consecutive markdown headings (`##`, `###`, `####`, …, ## tribe-a ### squad-a -- **fulll/bank** (1 match) - - [ ] [src/index.ts:3:14](https://github.com/fulll/bank/blob/main/src/index.ts#L3) +- **fulll/service-c** (1 match) + - [ ] [src/index.ts:3:14](https://github.com/fulll/service-c/blob/main/src/index.ts#L3) ## tribe-b ### squad-core + squad-mobile -- **fulll/tools-mobile** (1 match) - - [ ] [src/index.ts:1:1](https://github.com/fulll/tools-mobile/blob/main/src/index.ts#L1) +- **fulll/mobile-app** (1 match) + - [ ] [src/index.ts:1:1](https://github.com/fulll/mobile-app/blob/main/src/index.ts#L1) ### other -- **fulll/wizard-mobile** (1 match) - - [ ] [src/index.ts:2:5](https://github.com/fulll/wizard-mobile/blob/main/src/index.ts#L2) +- **fulll/mobile-app-legacy** (1 match) + - [ ] [src/index.ts:2:5](https://github.com/fulll/mobile-app-legacy/blob/main/src/index.ts#L2) ## other @@ -131,7 +131,7 @@ Each result carries its full hierarchy path (root first) in a `section` array: { "results": [ { - "repo": "fulll/tools-mobile", + "repo": "fulll/mobile-app", "section": ["tribe-b", "squad-core + squad-mobile"], "matches": [{ "path": "src/index.ts", "url": "...", "line": 1, "col": 1 }] } @@ -146,12 +146,12 @@ In the TUI, team sections appear as separator lines between repository rows, ind ```text ── tribe-a ── squad-a -▶ ◉ fulll/bank (1 match) +▶ ◉ fulll/service-c (1 match) ── tribe-b ── squad-core + squad-mobile -▶ ◉ fulll/tools-mobile (1 match) +▶ ◉ fulll/mobile-app (1 match) ── other -▶ ◉ fulll/wizard-mobile (1 match) +▶ ◉ fulll/mobile-app-legacy (1 match) ── other ▶ ◉ fulll/github-code-search (1 match) ``` @@ -252,7 +252,7 @@ Navigate to any **picked repo** (marked `◈`) and press **`t`** to enter re-pic ```text ── squad-core -▶ ◈ fulll/tools-mobile ← press t here +▶ ◈ fulll/mobile-app ← press t here ``` The hints bar shows a horizontal pick bar — exactly like team pick mode — with the current focused team highlighted in `[ brackets ]`: @@ -274,7 +274,7 @@ Pressing `0` or `u` in re-pick mode restores **all** repos from the same combine ```text ── squad-core + squad-mobile ← all repos restored -▶ ◉ fulll/tools-mobile +▶ ◉ fulll/mobile-app ``` In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for that combined section in the replay command. diff --git a/github-code-search.ts b/github-code-search.ts index ba22b6c..cbee9b3 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -33,7 +33,12 @@ import { import { checkForUpdate } from "./src/upgrade.ts"; import { runInteractive } from "./src/tui.ts"; import { generateCompletion, detectShell } from "./src/completions.ts"; -import { buildApiQuery, isRegexQuery, validateQuoteBalance } from "./src/regex.ts"; +import { + buildApiQuery, + detectPathWildcardLimitation, + isRegexQuery, + validateQuoteBalance, +} from "./src/regex.ts"; import type { OutputFormat, OutputType } from "./src/types.ts"; // Version + build metadata injected at compile time via --define (see build.ts). @@ -281,6 +286,14 @@ async function searchAction( process.exit(1); } + // Non-fatal: warn when path: is used with a "*" glob wildcard, which GitHub's + // API silently ignores rather than expanding — the search would otherwise + // appear to return no/wrong results with no explanation. + const pathWildcardWarning = detectPathWildcardLimitation(query); + if (pathWildcardWarning) { + console.error(style.yellow(`⚠ ${pathWildcardWarning}`)); + } + const org = opts.org; const format: OutputFormat = opts.format === "json" ? "json" : "markdown"; const outputType: OutputType = opts.outputType === "repo-only" ? "repo-only" : "repo-and-matches"; diff --git a/src/group.test.ts b/src/group.test.ts index 1ad6bf7..5e5ca9e 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -587,10 +587,10 @@ describe("applyTeamPickInTree", () => { ]; 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); + const tribe = updated.find((s) => s.label === "tribe-a")!; + const childLabels = (tribe.children ?? []).map((c) => c.label); expect(childLabels).not.toContain("squad-a + squad-b"); - const squadA = gamme.children!.find((c) => c.label === "squad-a")!; + const squadA = tribe.children!.find((c) => c.label === "squad-a")!; expect(squadA.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/shared"]); }); @@ -598,8 +598,8 @@ describe("applyTeamPickInTree", () => { 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")!; + const tribe = updated.find((s) => s.label === "tribe-a")!; + const squadA = tribe.children!.find((c) => c.label === "squad-a")!; expect(squadA.groups[0].pickedFrom).toBe("tribe-a > squad-a + squad-b"); }); @@ -607,8 +607,8 @@ describe("applyTeamPickInTree", () => { 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"); + const tribe = updated.find((s) => s.label === "tribe-a")!; + expect(tribe.children!.map((c) => c.label)).toContain("squad-b"); }); it("is a no-op when a path segment is not found", () => { @@ -669,10 +669,10 @@ describe("undoSectionPickInTree", () => { 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(); + const tribe = restored.find((s) => s.label === "tribe-a")!; + const childLabels = tribe.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")!; + const combined = tribe.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(); }); @@ -682,9 +682,9 @@ describe("undoSectionPickInTree", () => { 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 tribe = 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"); + expect(tribe.children!.map((c) => c.label)).not.toContain("squad-a"); }); it("is a no-op when no repo has a matching pickedFrom", () => { @@ -725,10 +725,10 @@ describe("moveRepoToSectionInTree", () => { 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")!; + const tribe = moved.find((s) => s.label === "tribe-a")!; + const squadB = tribe.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")!; + const squadA = tribe.children!.find((c) => c.label === "squad-a")!; expect(squadA.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); }); @@ -737,8 +737,8 @@ describe("moveRepoToSectionInTree", () => { 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"); + const tribe = moved.find((s) => s.label === "tribe-a")!; + expect(tribe.children!.map((c) => c.label)).toContain("squad-c"); }); it("is a no-op when the repo is not found anywhere in the tree", () => { @@ -759,12 +759,12 @@ describe("undoPickedRepoInTree", () => { 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 === "tribe-a")!; - const combined = gamme.children!.find((c) => c.label === "squad-a + squad-b")!; + const tribe = restored.find((s) => s.label === "tribe-a")!; + const combined = tribe.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(); // The other repo that was also moved stays picked. - const squadA = gamme.children!.find((c) => c.label === "squad-a")!; + const squadA = tribe.children!.find((c) => c.label === "squad-a")!; expect(squadA.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); }); @@ -856,8 +856,8 @@ describe("autoPickTeamsByCommonPrefix", () => { const tree = groupByTeamHierarchy(groups, [["tribe-", "squad-"]]); const resolved = autoPickTeamsByCommonPrefix(tree); expect(findCombinedSectionPaths(resolved)).toEqual([]); - const gamme = resolved.find((s) => s.label === "tribe-a")!; - const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; + const tribe = resolved.find((s) => s.label === "tribe-a")!; + const child = (tribe.children ?? []).find((c) => c.label === "squad-a")!; expect(child).toBeDefined(); expect(child.groups.map((g) => g.repoFullName).toSorted()).toEqual(["org/a", "org/b"]); }); @@ -884,38 +884,95 @@ describe("autoPickTeamsByCommonPrefix", () => { expect(autoPickTeamsByCommonPrefix(tree)).toEqual(tree); }); - it("investigation (#issue: chapter-secops not merging): does NOT merge combos that only share SOME members but no candidate is a literal prefix of every other candidate", () => { - // 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 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. + it("fix (#issue: cross-combo clustering): merges combos that don't share a literal prefix but share a common recurring team", () => { + // Reported behaviour: two different repos land in two different combined + // sections ("chapter-architect + chapter-frontend" and "chapter-architect + // + chapter-backend-node") because neither combo has one candidate that + // is a literal prefix of the other. Since "chapter-architect" recurs in + // BOTH combos while "chapter-frontend" and "chapter-backend-node" each + // appear only once, --pick-team-auto now clusters combos by their most + // frequently recurring shared team and resolves all of them to it. const groups = [ - makeGroup("org/a", ["chapter-secops", "chapter-validators-core"]), - makeGroup("org/b", ["chapter-head-of-frontend", "chapter-secops", "chapter-validators-core"]), - makeGroup("org/c", ["chapter-secops", "chapter-validators", "chapter-validators-core"]), + makeGroup("org/repo-a", ["chapter-architect", "chapter-frontend"]), + makeGroup("org/repo-b", ["chapter-architect", "chapter-backend-node"]), ]; const tree = groupByTeamHierarchy(groups, [["chapter-"]]); - const resolved = autoPickTeamsByCommonPrefix(tree); expect( - findCombinedSectionPaths(resolved) + findCombinedSectionPaths(tree) .map((p) => p[0]) .toSorted(), ).toEqual( [ - "chapter-head-of-frontend + chapter-secops + chapter-validators-core", - "chapter-secops + chapter-validators", - "chapter-secops + chapter-validators-core", + "chapter-architect + chapter-frontend", + "chapter-architect + chapter-backend-node", ].toSorted(), ); + + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([]); + expect(resolved).toHaveLength(1); + expect(resolved[0].label).toBe("chapter-architect"); + expect(resolved[0].groups.map((g) => g.repoFullName).toSorted()).toEqual([ + "org/repo-a", + "org/repo-b", + ]); + }); + + it("fix (#issue: cross-combo clustering): merges 3+ combos onto the team that recurs in the most of them", () => { + // "chapter-secops" recurs in all 3 combos below (unlike + // "chapter-validators-core", which only recurs in 2), so it wins and all + // 3 combos collapse under it. org/repo-c's own combo is already reduced + // from 3-way to 2-way by dropRedundantSubTeams beforehand, since + // chapter-validators-core is a redundant sub-team of chapter-validators + // (both present on org/repo-c). + const groups = [ + makeGroup("org/repo-a", ["chapter-secops", "chapter-validators-core"]), + makeGroup("org/repo-b", [ + "chapter-head-of-frontend", + "chapter-secops", + "chapter-validators-core", + ]), + makeGroup("org/repo-c", ["chapter-secops", "chapter-validators", "chapter-validators-core"]), + ]; + const tree = groupByTeamHierarchy(groups, [["chapter-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([]); + expect(resolved).toHaveLength(1); + expect(resolved[0].label).toBe("chapter-secops"); + expect(resolved[0].groups.map((g) => g.repoFullName).toSorted()).toEqual([ + "org/repo-a", + "org/repo-b", + "org/repo-c", + ]); + }); + + it("cross-combo clustering leaves a combo alone when no team recurs across combos at the same level", () => { + const groups = [ + makeGroup("org/repo-a", ["squad-a", "squad-b"]), + makeGroup("org/repo-b", ["squad-c", "squad-d"]), + ]; + const tree = groupByTeamHierarchy(groups, [["squad-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect( + findCombinedSectionPaths(resolved) + .map((p) => p[0]) + .toSorted(), + ).toEqual(["squad-a + squad-b", "squad-c + squad-d"]); + }); + + it("cross-combo clustering only merges combos under the SAME parent path", () => { + const groups = [ + makeGroup("org/repo-a", ["tribe-a", "chapter-architect", "chapter-frontend"]), + makeGroup("org/repo-b", ["tribe-b", "chapter-architect", "chapter-backend-node"]), + ]; + const tree = groupByTeamHierarchy(groups, [["tribe-", "chapter-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + // Each combo sits under a DIFFERENT tribe- parent, so they must NOT merge + // into a single cross-parent "chapter-architect" section. + expect(findCombinedSectionPaths(resolved)).toEqual([ + ["tribe-a", "chapter-architect + chapter-frontend"], + ["tribe-b", "chapter-architect + chapter-backend-node"], + ]); }); }); diff --git a/src/group.ts b/src/group.ts index be13f25..6efe052 100644 --- a/src/group.ts +++ b/src/group.ts @@ -747,30 +747,40 @@ 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. `"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. + * Auto-resolves every combined (`"a + b"`) section using two passes: * - * Applies independently at every hierarchy depth. Pure — does not mutate - * `sections`. + * 1. Any combo 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. `"tribe-lead-client"` for `"tribe-a + + * tribe-a-p1"`) — resolves to that team. + * 2. Any combo still unresolved is clustered against its siblings (combos + * under the same parent path): the team that recurs in the most of them + * (at least 2) wins, and every combo containing it merges into a single + * section named after that team. Combos that never share a recurring team + * with a sibling combo are left combined and unresolved (e.g. `"squad- + * frontend + squad-mobile"` alone). + * + * Both passes apply the same tree update as an explicit `--pick-team` + * assignment. Applies independently at every hierarchy depth. Pure — does + * not mutate `sections`. */ export function autoPickTeamsByCommonPrefix(sections: TeamSection[]): TeamSection[] { let result = sections; + const stillCombined: string[][] = []; for (const path of findCombinedSectionPaths(sections)) { const combinedLabel = path[path.length - 1]; const candidates = combinedLabel.split(" + ").map((c) => c.trim()); const winner = findCommonPrefixTeam(candidates); - if (winner === undefined) continue; + if (winner === undefined) { + stillCombined.push(path); + continue; + } result = applyTeamPickInTree(result, path, winner); } - return result; + return clusterRemainingCombosByRecurringTeam(result, stillCombined); } /** @@ -783,6 +793,81 @@ function findCommonPrefixTeam(candidates: string[]): string | undefined { return winners.length === 1 ? winners[0] : undefined; } +/** + * Second-pass resolution for combined sections that `findCommonPrefixTeam` + * couldn't resolve on their own (no candidate is a literal prefix of every + * other). Groups the still-unresolved combo paths by parent path (siblings + * under the same ancestor) and delegates each group to + * `clusterGroupByRecurringTeam`. Pure — does not mutate `sections`. + */ +function clusterRemainingCombosByRecurringTeam( + sections: TeamSection[], + combinedPaths: string[][], +): TeamSection[] { + let result = sections; + + const byParent = new Map(); + for (const path of combinedPaths) { + const parentKey = path.slice(0, -1).join(PATH_SEPARATOR); + const group = byParent.get(parentKey) ?? []; + group.push(path); + byParent.set(parentKey, group); + } + + for (const paths of byParent.values()) { + result = clusterGroupByRecurringTeam(result, paths); + } + + return result; +} + +/** + * Repeatedly finds the team name that recurs in the MOST of the given combo + * paths (all siblings under the same parent, at least 2 combos required — + * ties broken alphabetically) and merges every combo containing it into a + * single section named after that team, the same tree update as an explicit + * `--pick-team` assignment. Repeats until no team recurs across 2+ of the + * remaining combos. Combos that never share a recurring team with another + * combo are left combined and unresolved. Pure — does not mutate `sections`. + */ +function clusterGroupByRecurringTeam(sections: TeamSection[], paths: string[][]): TeamSection[] { + let result = sections; + let remaining = paths; + + while (remaining.length >= 2) { + const counts = new Map(); + for (const path of remaining) { + const candidates = path[path.length - 1].split(" + ").map((c) => c.trim()); + for (const team of candidates) counts.set(team, (counts.get(team) ?? 0) + 1); + } + + let winner: string | undefined; + let winnerCount = 1; + for (const [team, count] of [...counts.entries()].toSorted((a, b) => + a[0].localeCompare(b[0]), + )) { + if (count > winnerCount) { + winner = team; + winnerCount = count; + } + } + if (winner === undefined) break; + + const matched = remaining.filter((path) => + path[path.length - 1] + .split(" + ") + .map((c) => c.trim()) + .includes(winner as string), + ); + for (const path of matched) { + result = applyTeamPickInTree(result, path, winner); + } + remaining = remaining.filter((path) => !matched.includes(path)); + } + + return result; +} + /** Returns whether `path` (root-first ancestor labels) resolves to an actual node in the tree. */ function pathExistsInTree(sections: TeamSection[], path: string[]): boolean { let level = sections; diff --git a/src/regex.test.ts b/src/regex.test.ts index e5dbac0..a3bf36d 100644 --- a/src/regex.test.ts +++ b/src/regex.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { buildApiQuery, escapeApiTerm, isRegexQuery, validateQuoteBalance } from "./regex.ts"; +import { + buildApiQuery, + detectPathWildcardLimitation, + escapeApiTerm, + isRegexQuery, + validateQuoteBalance, +} from "./regex.ts"; // ─── isRegexQuery ───────────────────────────────────────────────────────────── @@ -377,3 +383,44 @@ describe("validateQuoteBalance (issue #149)", () => { expect(validateQuoteBalance('"foo" "bar"')).toBeNull(); }); }); + +describe("detectPathWildcardLimitation (issue: path:*.ext silently returns no results)", () => { + it("warns when path: is used with a glob-style extension wildcard", () => { + // Regression: `path:*.tf ACME123456789.EXAMPLE_CUSTOMER` returns zero results, + // while the equivalent `language:hcl ACME123456789.EXAMPLE_CUSTOMER` works — + // GitHub's code search API silently ignores the `*` wildcard character + // rather than expanding it as a glob, so `path:*.tf` is effectively + // matched as `path:.tf`, which rarely matches any real file path. + const warn = detectPathWildcardLimitation("path:*.tf ACME123456789.EXAMPLE_CUSTOMER"); + expect(warn).toBeDefined(); + expect(warn).toContain("path:*.tf"); + expect(warn).toContain("language:"); + expect(warn).toContain("extension:"); + }); + + it("returns undefined for a query with no path: qualifier", () => { + expect( + detectPathWildcardLimitation("language:hcl ACME123456789.EXAMPLE_CUSTOMER"), + ).toBeUndefined(); + }); + + it("returns undefined when path: has no wildcard character", () => { + expect(detectPathWildcardLimitation("path:terraform language:hcl connect")).toBeUndefined(); + }); + + it("detects the wildcard even when path: is not the first qualifier", () => { + const warn = detectPathWildcardLimitation("connect language:hcl path:*.tf"); + expect(warn).toBeDefined(); + expect(warn).toContain("path:*.tf"); + }); + + it("detects a wildcard-quoted path value", () => { + const warn = detectPathWildcardLimitation('connect path:"*.tf"'); + expect(warn).toBeDefined(); + expect(warn).toContain('path:"*.tf"'); + }); + + it("returns undefined for an empty query", () => { + expect(detectPathWildcardLimitation("")).toBeUndefined(); + }); +}); diff --git a/src/regex.ts b/src/regex.ts index eda437f..8d4bb05 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -64,6 +64,37 @@ export function validateQuoteBalance(query: string): string | null { ); } +/** + * Detects a `path:` qualifier whose value contains a `*` wildcard character + * (e.g. `path:*.tf`) and returns a human-readable warning explaining why it + * silently returns no (or wrong) results, with a suggested workaround. + * + * GitHub's code search API documents `*` (along with several other symbols) + * as a character it **ignores** rather than expands as a glob — so + * `path:*.tf` is effectively matched as `path:.tf`, which rarely matches any + * real file path. The `path:` qualifier itself is meant for matching a + * directory location, not a filename extension; `language:` or `extension:` + * should be used instead to filter by file type. + * @see https://docs.github.com/en/search-github/searching-on-github/searching-code + * + * Returns `undefined` when the query has no `path:` qualifier, or when its + * value has no wildcard character. Pure — no I/O. + */ +export function detectPathWildcardLimitation(query: string): string | undefined { + const m = query.match(/(?:^|\s)path:("[^"]*"|\S+)/); + if (!m || !m[1]) return undefined; + const value = m[1]; + if (!value.includes("*")) return undefined; + + return ( + `The query contains "path:${value}", but GitHub's code search API ignores the "*" ` + + "wildcard character rather than expanding it as a glob, so this qualifier will " + + "silently match few or no files. The path: qualifier matches a directory location, " + + 'not a filename extension — use "language:" or "extension:" instead ' + + "(e.g. language:hcl or extension:tf)." + ); +} + /** * Given a raw query string (possibly mixing GitHub qualifiers and a /regex/flags * token), returns: From 7d68a797a8ce5f822c9b8fd2785e008c27eaddc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 11:50:56 +0200 Subject: [PATCH 5/5] Fix: stop auto-dropping same-prefix teams from combo labels (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Copilot review on PR #201: a team name being a string-prefix of another matching team does NOT imply GitHub team membership in it — memberships are independent. dropRedundantSubTeams silently discarded matching teams from ALL grouping runs based on this false assumption (e.g. chapter-architect-a dropped whenever chapter-architect was also present), hiding real team memberships from users who never opted into that behavior. Removed dropRedundantSubTeams entirely; bucketSingleLevel now keeps every matching team in the combined-section label. Noisy sub-team prefixes remain reducible via the existing, explicit --exclude-team-prefixes option. Updated the mega-combo test to assert all teams are kept, added a companion test demonstrating the --exclude-team-prefixes workaround, and corrected the affected docs/usage/team-grouping.md paragraph. --- docs/usage/team-grouping.md | 2 +- src/group.test.ts | 62 ++++++++++++++++++++++++++++--------- src/group.ts | 21 ++----------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index a31caf8..39892f9 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -47,7 +47,7 @@ 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. +Every matching team is kept in the combined-section label, even when one team's name happens to be a prefix of another's (e.g. `chapter-architect` and `chapter-architect-a`) — GitHub team memberships are independent, so a name prefix doesn't imply membership in the other team. If a chain of narrow, noisy sub-teams (e.g. many `chapter-validators-*` teams) is inflating your combined labels, use [`--exclude-team-prefixes`](#excluding-noisy-team-prefixes) to strip them explicitly before grouping. 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. diff --git a/src/group.test.ts b/src/group.test.ts index 5e5ca9e..307c224 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -58,6 +58,25 @@ describe("excludeTeamsByPrefix", () => { expect(result[0].teams).toEqual(["chapter-secops"]); }); + it("is the recommended opt-in workaround for the chapter-architect mega-combo (see review comment on dropRedundantSubTeams)", () => { + // groupByTeamPrefix no longer auto-drops narrower same-prefix teams (a + // team-name prefix does not imply GitHub team membership) — trimming + // noisy sub-team prefixes is an explicit, opt-in choice via + // --exclude-team-prefixes / excludeTeamsByPrefix. + const groups = [ + makeGroup("org/a", [ + "chapter-architect", + "chapter-architect-a", + "chapter-architect-b", + "chapter-head-of-frontend", + ]), + ]; + const filtered = excludeTeamsByPrefix(groups, ["chapter-architect-"]); + const sections = groupByTeamPrefix(filtered, ["chapter-"]); + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe("chapter-architect + chapter-head-of-frontend"); + }); + 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-"]); @@ -157,14 +176,17 @@ describe("groupByTeamPrefix — multi-team repos", () => { 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. + it("fix (#review: chapter-architect mega-combo): keeps every matching team in the combo label — a name prefix does not imply GitHub team membership", () => { + // Regression: an earlier version of this function dropped any team whose + // name was a string-prefix-extension of another matching team (e.g. + // chapter-architect-a dropped because chapter-architect was also + // present), assuming the broader team name implied membership in the + // narrower one. Code review correctly pointed out this is unsound — + // GitHub team memberships are independent of naming, so a repo can + // genuinely and separately belong to both. Every matching team must + // stay in the combo label; --exclude-team-prefixes is the explicit, + // opt-in mechanism for trimming noisy sub-team prefixes (see the + // excludeTeamsByPrefix describe block below). const groups = [ makeGroup("org/a", [ "chapter-architect", @@ -181,7 +203,20 @@ describe("groupByTeamPrefix — multi-team repos", () => { ]; const sections = groupByTeamPrefix(groups, ["chapter-"]); expect(sections).toHaveLength(1); - expect(sections[0].label).toBe("chapter-architect + chapter-head-of-frontend"); + expect(sections[0].label).toBe( + [ + "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", + ].join(" + "), + ); }); }); @@ -919,12 +954,9 @@ describe("autoPickTeamsByCommonPrefix", () => { }); it("fix (#issue: cross-combo clustering): merges 3+ combos onto the team that recurs in the most of them", () => { - // "chapter-secops" recurs in all 3 combos below (unlike - // "chapter-validators-core", which only recurs in 2), so it wins and all - // 3 combos collapse under it. org/repo-c's own combo is already reduced - // from 3-way to 2-way by dropRedundantSubTeams beforehand, since - // chapter-validators-core is a redundant sub-team of chapter-validators - // (both present on org/repo-c). + // Both "chapter-secops" and "chapter-validators-core" recur in all 3 + // combos below — a tie broken alphabetically in favor of + // "chapter-secops" — so all 3 combos collapse under it. const groups = [ makeGroup("org/repo-a", ["chapter-secops", "chapter-validators-core"]), makeGroup("org/repo-b", [ diff --git a/src/group.ts b/src/group.ts index 6efe052..41dae53 100644 --- a/src/group.ts +++ b/src/group.ts @@ -81,9 +81,7 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCount = new Map(); for (const g of matchingGroups) { - const matchingTeams = dropRedundantSubTeams( - (g.teams ?? []).filter((t) => t.startsWith(prefix)), - ); + const matchingTeams = (g.teams ?? []).filter((t) => t.startsWith(prefix)); const count = matchingTeams.length; if (!byCount.has(count)) byCount.set(count, []); byCount.get(count)!.push(g); @@ -95,9 +93,8 @@ function bucketSingleLevel(remaining: Set, prefix: string): TeamSecti const byCombo = new Map(); for (const g of groupsInBucket) { - const matchingTeams = dropRedundantSubTeams( - (g.teams ?? []).filter((t) => t.startsWith(prefix)), - ) + const matchingTeams = (g.teams ?? []) + .filter((t) => t.startsWith(prefix)) .toSorted() .join(" + "); if (!byCombo.has(matchingTeams)) byCombo.set(matchingTeams, []); @@ -112,18 +109,6 @@ 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 ─────────────────────────────── /**