From 9326edc0317a7aa49ba177ef8e5af1dab731976c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 30 Aug 2026 20:00:01 +0200 Subject: [PATCH] Add --pick-team-auto: auto-resolve combined sections by common prefix Auto-resolves a combined team section (e.g. "gamme-lead-client + gamme-lead-client-p1") to whichever team name is a literal prefix of every other team name in the combo, at any hierarchy depth. Combos with no common-prefix team are left combined/unresolved, and an explicit --pick-team for the same section always wins (auto-pick runs after explicit assignments). Closes #197 --- docs/reference/cli-options.md | 29 +++++++-------- docs/usage/team-grouping.md | 19 ++++++++++ github-code-search.ts | 26 +++++++++++++- src/completions.test.ts | 12 +++++++ src/completions.ts | 6 ++++ src/group.test.ts | 67 +++++++++++++++++++++++++++++++++++ src/group.ts | 37 +++++++++++++++++++ src/output.test.ts | 23 ++++++++++++ src/output.ts | 14 +++++++- src/tui.ts | 2 ++ 10 files changed, 219 insertions(+), 16 deletions(-) diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index fde4c48..f59a32c 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -31,20 +31,21 @@ 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). | -| `--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). | +| `--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 c262645..dde817b 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -202,6 +202,25 @@ The flag is repeatable — add one `--pick-team` per combined section to resolve If the combined label or path is not found (typo, ambiguous, or the section was not formed), a warning is emitted on stderr listing the available combined sections — the run continues without error. +## 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`: + +```bash +github-code-search query "useFeatureFlag" --org fulll \ + --group-by-team-prefix gamme- \ + --pick-team-auto +``` + +```text +## gamme-lead-client + gamme-lead-client-p1 → ## gamme-lead-client +``` + +- 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. +- Applies independently **at every hierarchy depth**, not just the top level. +- 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. + ## 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 2f8b65e..6306bf0 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -22,6 +22,7 @@ import { formatRetryWait } from "./src/api-utils.ts"; import { buildOutput } from "./src/output.ts"; import { applyTeamPickInTree, + autoPickTeamsByCommonPrefix, findCombinedSectionPaths, flattenTeamHierarchy, groupByTeamHierarchy, @@ -204,6 +205,19 @@ function addSearchOptions(cmd: Command): Command { (val: string, list: string[]) => [...list, val], [] as string[], ) + .option( + "--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.', + "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.", + "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#auto-pick-by-common-prefix", + ].join("\n"), + false, + ) .option( "--no-cache", "Bypass the 24 h team-list cache and re-fetch teams from GitHub (only applies with --group-by-team-prefix).", @@ -233,6 +247,7 @@ async function searchAction( excludeTemplateRepositories: boolean; groupByTeamPrefix: string; pickTeam: string[]; + pickTeamAuto?: boolean; cache: boolean; regexHint?: string; }, @@ -392,10 +407,17 @@ async function searchAction( pickTeams[resolution.path.join(" > ")] = resolution.chosen; } + // --pick-team-auto runs AFTER explicit assignments so an explicit --pick-team + // for the same section always wins (it no longer exists as a combined section + // by the time auto-pick runs, so auto-pick naturally skips it). + if (opts.pickTeamAuto) { + sections = autoPickTeamsByCommonPrefix(sections); + } + // Warn about combined sections that still have no pick assigned, so the user // knows which labels to add to the next replay command or interactive session. const unresolved = findCombinedSectionPaths(sections); - if (unresolved.length > 0 && opts.pickTeam.length > 0) { + if (unresolved.length > 0 && (opts.pickTeam.length > 0 || opts.pickTeamAuto)) { process.stderr.write( `note: ${unresolved.length} combined section${unresolved.length !== 1 ? "s" : ""} still unresolved ` + `(press "p" in TUI or use --pick-team to assign):\n` + @@ -414,6 +436,7 @@ async function searchAction( includeArchived, excludeTemplates, groupByTeamPrefix: opts.groupByTeamPrefix, + pickTeamAuto: opts.pickTeamAuto, regexHint: opts.regexHint, pickTeams: Object.keys(pickTeams).length > 0 ? pickTeams : undefined, }), @@ -474,6 +497,7 @@ async function searchAction( includeArchived, excludeTemplates, opts.groupByTeamPrefix, + Boolean(opts.pickTeamAuto), opts.regexHint ?? "", Object.keys(pickTeams).length > 0 ? pickTeams : {}, ); diff --git a/src/completions.test.ts b/src/completions.test.ts index 4ca14d6..60cbba0 100644 --- a/src/completions.test.ts +++ b/src/completions.test.ts @@ -31,6 +31,10 @@ describe("generateCompletion", () => { expect(script).toContain("--regex-hint"); }); + it("contains --pick-team-auto", () => { + expect(generateCompletion("bash")).toContain("--pick-team-auto"); + }); + it("contains format values (markdown, json)", () => { const script = generateCompletion("bash"); expect(script).toContain("markdown"); @@ -75,6 +79,10 @@ describe("generateCompletion", () => { expect(script).toContain("--regex-hint"); }); + it("contains --pick-team-auto", () => { + expect(generateCompletion("zsh")).toContain("--pick-team-auto"); + }); + it("contains a 'compdef' directive (zsh-style)", () => { const script = generateCompletion("zsh"); expect(script).toContain("compdef "); @@ -107,6 +115,10 @@ describe("generateCompletion", () => { expect(script).toContain("regex-hint"); }); + it("contains pick-team-auto", () => { + expect(generateCompletion("fish")).toContain("pick-team-auto"); + }); + 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 005ce3e..b30b701 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -51,6 +51,12 @@ const OPTIONS = [ takesArg: true, values: [], }, + { + flag: "pick-team-auto", + description: "Auto-resolve combined team sections by common prefix", + takesArg: false, + values: [], + }, { flag: "no-interactive", description: "Disable interactive mode", diff --git a/src/group.test.ts b/src/group.test.ts index be905a4..ea143f3 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { applyTeamPick, applyTeamPickInTree, + autoPickTeamsByCommonPrefix, findCombinedSectionPaths, flattenTeamHierarchy, flattenTeamSections, @@ -717,6 +718,72 @@ describe("findCombinedSectionPaths", () => { }); }); +// ─── autoPickTeamsByCommonPrefix ─────────────────────────────────────────────── + +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 resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([]); + const winner = resolved.find((s) => s.label === "gamme-lead-client")!; + expect(winner.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + }); + + it("leaves a combined section unresolved when no team is a prefix of the others", () => { + const groups = [makeGroup("org/a", ["squad-frontend", "squad-mobile"])]; + const tree = groupByTeamHierarchy(groups, [["squad-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([["squad-frontend + squad-mobile"]]); + }); + + 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 resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([]); + expect(resolved.map((s) => s.label)).toEqual(["gamme-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"]), + ]; + const tree = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const resolved = autoPickTeamsByCommonPrefix(tree); + expect(findCombinedSectionPaths(resolved)).toEqual([]); + const gamme = resolved.find((s) => s.label === "gamme-client")!; + const child = (gamme.children ?? []).find((c) => c.label === "squad-a")!; + expect(child).toBeDefined(); + expect(child.groups.map((g) => g.repoFullName)).toEqual(["org/a"]); + }); + + it("an explicit --pick-team resolution is left untouched (no longer combined) when auto-pick runs after", () => { + const groups = [makeGroup("org/a", ["squad-frontend", "squad-mobile"])]; + const tree = groupByTeamHierarchy(groups, [["squad-"]]); + const picked = applyTeamPickInTree(tree, ["squad-frontend + squad-mobile"], "squad-frontend"); + const resolved = autoPickTeamsByCommonPrefix(picked); + expect(resolved).toEqual(picked); + }); + + 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 before = JSON.stringify(tree); + autoPickTeamsByCommonPrefix(tree); + expect(JSON.stringify(tree)).toBe(before); + }); + + it("returns the tree unchanged when there is no combined section", () => { + const groups = [makeGroup("org/a", ["squad-front"])]; + const tree = groupByTeamHierarchy(groups, [["squad-"]]); + expect(autoPickTeamsByCommonPrefix(tree)).toEqual(tree); + }); +}); + // ─── flattenTeamSections ────────────────────────────────────────────────────── describe("flattenTeamSections", () => { diff --git a/src/group.ts b/src/group.ts index fb3ecab..5b34017 100644 --- a/src/group.ts +++ b/src/group.ts @@ -693,6 +693,43 @@ export function findCombinedSectionPaths(sections: TeamSection[]): string[][] { return paths; } +/** + * 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 + * 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. + * + * Applies independently at every hierarchy depth. Pure — does not mutate + * `sections`. + */ +export function autoPickTeamsByCommonPrefix(sections: TeamSection[]): TeamSection[] { + let result = sections; + + 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; + + result = applyTeamPickInTree(result, path, winner); + } + + return result; +} + +/** + * Returns the one candidate that is a literal string-prefix of every other + * candidate (its common-prefix "parent"), or `undefined` when no single + * candidate satisfies that for all the others. + */ +function findCommonPrefixTeam(candidates: string[]): string | undefined { + const winners = candidates.filter((c) => candidates.every((other) => other.startsWith(c))); + return winners.length === 1 ? winners[0] : undefined; +} + /** 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/output.test.ts b/src/output.test.ts index 8eed3b5..4c1b5a3 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -198,6 +198,19 @@ describe("buildReplayCommand", () => { expect(cmd).not.toContain("--group-by-team-prefix"); }); + 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 cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).toContain("--pick-team-auto"); + }); + + it("does not include --pick-team-auto when pickTeamAuto is false (default)", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); + expect(cmd).not.toContain("--pick-team-auto"); + }); + it("includes --regex-hint when regexHint is set", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const opts: ReplayOptions = { regexHint: '"axios"' }; @@ -844,6 +857,16 @@ describe("buildOutput", () => { expect(out).toContain("--include-archived"); }); + 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-", + pickTeamAuto: true, + }); + const parsed = JSON.parse(out); + expect(parsed.replayCommand).toContain("--pick-team-auto"); + }); + 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 e50665b..6e46be3 100644 --- a/src/output.ts +++ b/src/output.ts @@ -35,6 +35,9 @@ export interface ReplayOptions { includeArchived?: boolean; excludeTemplates?: boolean; groupByTeamPrefix?: string; + /** Mirrors `--pick-team-auto` — auto-resolves combined sections by common + * prefix (see `autoPickTeamsByCommonPrefix`). */ + pickTeamAuto?: boolean; /** When set, appends `--regex-hint ` to the replay command so the * result set from a regex query can be reproduced exactly. */ regexHint?: string; @@ -59,6 +62,7 @@ export function buildReplayCommand( includeArchived, excludeTemplates, groupByTeamPrefix, + pickTeamAuto, regexHint, pickTeams, } = options; @@ -110,6 +114,9 @@ export function buildReplayCommand( if (groupByTeamPrefix) { parts.push(`--group-by-team-prefix ${shellQuote(groupByTeamPrefix)}`); } + if (pickTeamAuto) { + parts.push("--pick-team-auto"); + } if (regexHint) { parts.push(`--regex-hint ${shellQuote(regexHint)}`); } @@ -390,7 +397,12 @@ export function buildOutput( outputType: OutputType = "repo-and-matches", extraOptions: Pick< ReplayOptions, - "includeArchived" | "excludeTemplates" | "groupByTeamPrefix" | "regexHint" | "pickTeams" + | "includeArchived" + | "excludeTemplates" + | "groupByTeamPrefix" + | "pickTeamAuto" + | "regexHint" + | "pickTeams" > = {}, ): string { const options: ReplayOptions = { format, outputType, ...extraOptions }; diff --git a/src/tui.ts b/src/tui.ts index 6d53a9e..58269ca 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -143,6 +143,7 @@ export async function runInteractive( includeArchived = false, excludeTemplates = false, groupByTeamPrefix = "", + pickTeamAuto = false, regexHint = "", initialPickTeams: Record = {}, ): Promise { @@ -701,6 +702,7 @@ export async function runInteractive( includeArchived, excludeTemplates, groupByTeamPrefix, + pickTeamAuto, regexHint: regexHint || undefined, pickTeams: Object.keys(confirmedPicks).length > 0 ? confirmedPicks : undefined, }),