diff --git a/src/group.test.ts b/src/group.test.ts index b2b32f2..f611cda 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { applyTeamPick, consolidateTeamHierarchy, + flattenTeamHierarchy, flattenTeamSections, groupByTeamHierarchy, groupByTeamPrefix, @@ -422,6 +423,77 @@ describe("consolidateTeamHierarchy", () => { }); }); +// ─── flattenTeamHierarchy ───────────────────────────────────────────────────── + +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"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const flat = flattenTeamHierarchy(sections); + expect(flat).toHaveLength(2); + expect(flat[0].sectionPath).toEqual([ + { label: "gamme-client", level: 0 }, + { label: "squad-dashboard", 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"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const flat = flattenTeamHierarchy(sections); + // First leaf (alphabetically squad-billing comes first) gets both headings + expect(flat[0].sectionPath).toEqual([ + { label: "gamme-client", level: 0 }, + { label: "squad-billing", 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 }]); + }); + + 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/b", ["chapter-backend"]), + ]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "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-"]]); + 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 }]); + }); + + it("does not mutate the input tree", () => { + const groups = [makeGroup("org/a", ["gamme-client", "squad-dashboard"])]; + const sections = groupByTeamHierarchy(groups, [["gamme-", "squad-"]]); + const before = JSON.stringify(sections); + flattenTeamHierarchy(sections); + expect(JSON.stringify(sections)).toBe(before); + }); + + it("returns an empty array for an empty tree", () => { + expect(flattenTeamHierarchy([])).toEqual([]); + }); +}); + // ─── flattenTeamSections ────────────────────────────────────────────────────── describe("flattenTeamSections", () => { diff --git a/src/group.ts b/src/group.ts index 40de18e..8ff5357 100644 --- a/src/group.ts +++ b/src/group.ts @@ -377,6 +377,68 @@ export function flattenTeamSections(sections: TeamSection[]): RepoGroup[] { return result; } +/** One heading transition: a label at a given nesting depth. */ +type PathEntry = { label: string; level: number }; + +/** + * Flattens a `groupByTeamHierarchy` tree into a plain `RepoGroup[]`, tagging + * the first repo of each leaf section with `sectionPath` — the list of + * heading transitions (root-to-leaf labels, each with its `level`) that are + * *new* since the previous leaf. Siblings under an unchanged ancestor don't + * repeat that ancestor's heading, mirroring how nested markdown headings are + * only printed once per transition. + * + * Consumers that need the full current path for every repo (e.g. JSON + * output) should maintain a running cursor and, whenever `sectionPath` is + * set, replace `cursor.slice(0, sectionPath[0].level)` with `sectionPath`. + * + * Note: the original `RepoGroup` objects are not mutated; new objects with + * the `sectionPath` field added are returned. + */ +export function flattenTeamHierarchy(sections: TeamSection[]): RepoGroup[] { + const result: RepoGroup[] = []; + let previousPath: PathEntry[] = []; + + function emitLeafGroups(groups: RepoGroup[], path: PathEntry[]): void { + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (i === 0) { + const divergeAt = firstDivergingIndex(previousPath, path); + const newHeadings = path.slice(divergeAt); + result.push(newHeadings.length > 0 ? { ...g, sectionPath: newHeadings } : { ...g }); + previousPath = path; + } else { + // Remove any pre-existing sectionPath from non-first entries + const { sectionPath: _removed, ...rest } = g; + void _removed; + result.push(rest as RepoGroup); + } + } + } + + function visit(node: TeamSection, ancestors: PathEntry[]): void { + const path = [...ancestors, { label: node.label, level: node.level ?? 0 }]; + // A node can own repos directly *and* have nested children at once (see + // `TeamSection`) — emit its own groups under its own heading first, then + // descend into children so none of its repos are dropped. + if (node.groups.length > 0) emitLeafGroups(node.groups, path); + if (node.children) { + for (const child of node.children) visit(child, path); + } + } + + for (const section of sections) visit(section, []); + return result; +} + +function firstDivergingIndex(a: PathEntry[], b: PathEntry[]): number { + let i = 0; + while (i < a.length && i < b.length && a[i].label === b[i].label && a[i].level === b[i].level) { + i++; + } + return i; +} + // ─── Internal helpers ───────────────────────────────────────────────────────── /** diff --git a/src/output.test.ts b/src/output.test.ts index 39f38e9..833a264 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -214,6 +214,22 @@ describe("buildReplayCommand", () => { expect(cmd).not.toContain("--regex-hint"); }); + it("includes --group-by-team-prefix-consolidate when consolidateTeamSections is true", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const opts: ReplayOptions = { + groupByTeamPrefix: "gamme-/squad-", + consolidateTeamSections: true, + }; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).toContain("--group-by-team-prefix-consolidate"); + }); + + it("does not include --group-by-team-prefix-consolidate when consolidateTeamSections is false (default)", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); + expect(cmd).not.toContain("--group-by-team-prefix-consolidate"); + }); + it("emits --pick-team for each entry in pickTeams", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const opts: ReplayOptions = { @@ -477,6 +493,90 @@ describe("buildMarkdownOutput", () => { const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only"); expect(out).not.toContain("selected"); }); + + it("renders each sectionPath entry as a heading at 2 + level (## / ###)", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"]), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-dashboard", level: 1 }, + ], + }, + ]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out).toContain("## gamme-client"); + expect(out).toContain("### squad-dashboard"); + }); + + it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"]), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-billing", level: 1 }, + ], + }, + { + ...makeGroup("myorg/repoB", ["b.ts"]), + sectionPath: [{ label: "squad-dashboard", level: 1 }], + }, + ]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect((out.match(/^## /gm) ?? []).length).toBe(1); + expect((out.match(/^### /gm) ?? []).length).toBe(2); + }); + + it("caps heading depth at H6 for very deep chains", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"]), + sectionPath: [{ label: "deep", level: 10 }], + }, + ]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out).toContain("###### deep"); + expect(out).not.toContain("####### deep"); + }); + + it("does not lose the heading when the repo that carries sectionPath is deselected", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"], { repoSelected: false }), + sectionPath: [{ label: "gamme-client", 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("myorg/repoB"); + }); + + it("does not lose the heading when the repo that carries sectionPath has no selected matches", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"], { extractSelected: [false] }), + sectionPath: [{ label: "gamme-client", level: 0 }], + }, + makeGroup("myorg/repoB", ["b.ts"]), + ]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out).toContain("## gamme-client"); + }); + + it("does not lose a flat sectionLabel heading when its bearing repo is deselected", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"], { repoSelected: false }), + sectionLabel: "squad-frontend", + }, + makeGroup("myorg/repoB", ["b.ts"]), + ]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out).toContain("## squad-frontend"); + expect((out.match(/^## /gm) ?? []).length).toBe(1); + }); }); describe("buildJsonOutput", () => { @@ -534,6 +634,45 @@ describe("buildJsonOutput", () => { expect(parsed.results[0].repo).toBe("myorg/repoA"); expect(parsed.results[0].matches).toBeUndefined(); }); + + it("includes the full section path on a repo tagged with sectionPath", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"]), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-dashboard", level: 1 }, + ], + }, + ]; + const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); + expect(parsed.results[0].section).toEqual(["gamme-client", "squad-dashboard"]); + }); + + it("carries the reconstructed path forward to a sibling repo missing the shared ancestor", () => { + const groups: RepoGroup[] = [ + { + ...makeGroup("myorg/repoA", ["a.ts"]), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-billing", level: 1 }, + ], + }, + { + ...makeGroup("myorg/repoB", ["b.ts"]), + sectionPath: [{ label: "squad-dashboard", 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"]); + }); + + it("omits the section field when no sectionPath/sectionLabel is present", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); + expect(parsed.results[0].section).toBeUndefined(); + }); }); // ─── segmentLineCol ─────────────────────────────────────────────────────────── @@ -730,4 +869,14 @@ describe("buildOutput", () => { const parsed = JSON.parse(out); expect(parsed.replayCommand).toContain("--group-by-team-prefix 'squad-'"); }); + + it("threads consolidateTeamSections 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-", + consolidateTeamSections: true, + }); + const parsed = JSON.parse(out); + expect(parsed.replayCommand).toContain("--group-by-team-prefix-consolidate"); + }); }); diff --git a/src/output.ts b/src/output.ts index 1cf52df..fcc4396 100644 --- a/src/output.ts +++ b/src/output.ts @@ -35,6 +35,9 @@ export interface ReplayOptions { includeArchived?: boolean; excludeTemplates?: boolean; groupByTeamPrefix?: string; + /** Mirrors `--group-by-team-prefix-consolidate` — collapses single-branch + * nesting chains into one heading (see `consolidateTeamHierarchy`). */ + consolidateTeamSections?: 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, + consolidateTeamSections, regexHint, pickTeams, } = options; @@ -110,6 +114,9 @@ export function buildReplayCommand( if (groupByTeamPrefix) { parts.push(`--group-by-team-prefix ${shellQuote(groupByTeamPrefix)}`); } + if (consolidateTeamSections) { + parts.push("--group-by-team-prefix-consolidate"); + } if (regexHint) { parts.push(`--regex-hint ${shellQuote(regexHint)}`); } @@ -230,16 +237,44 @@ export function buildMarkdownOutput( lines.push(buildSelectionSummary(groups)); lines.push(""); + // Track section markers across ALL groups (not just visible ones) so a + // heading is never lost when the repo that first carried it gets filtered + // out below (deselected, or with no selected matches) — see review on #187. + let pendingSectionLabel: string | undefined; + let pendingSectionPath: NonNullable = []; + for (const group of groups) { + if (group.sectionLabel !== undefined) { + pendingSectionLabel = group.sectionLabel; + } + if (group.sectionPath !== undefined && group.sectionPath.length > 0) { + pendingSectionPath = [ + ...pendingSectionPath.slice(0, group.sectionPath[0].level), + ...group.sectionPath, + ]; + } + if (!group.repoSelected) continue; const matches = selectedMatches(group); if (matches.length === 0) continue; - // Section header (emitted before the first repo in a new team section) - if (group.sectionLabel !== undefined) { + // Section header(s), emitted before the first *visible* repo of a new + // section. `sectionLabel` is the flat single-level marker + // (`groupByTeamPrefix`); `sectionPath` is the hierarchical marker + // (`groupByTeamHierarchy`) — only one of the two is ever set at a time. + if (pendingSectionLabel !== undefined) { lines.push(""); - lines.push(`## ${group.sectionLabel}`); + lines.push(`## ${pendingSectionLabel}`); lines.push(""); + pendingSectionLabel = undefined; + } else if (pendingSectionPath.length > 0) { + lines.push(""); + // Markdown has no heading deeper than H6 — cap depth there. + for (const heading of pendingSectionPath) { + lines.push(`${"#".repeat(Math.min(2 + heading.level, 6))} ${heading.label}`); + } + lines.push(""); + pendingSectionPath = []; } const matchCount = selectedMatches(group).length; @@ -279,10 +314,30 @@ export function buildJsonOutput( outputType: OutputType = "repo-and-matches", options: ReplayOptions = {}, ): string { + // Reconstruct each group's full hierarchy path (root→leaf labels) from the + // flattened `sectionPath` diff markers (`groupByTeamHierarchy` + + // `flattenTeamHierarchy`). Tracked across ALL groups (not just selected + // ones) so a heading transition on a filtered-out repo still advances the + // cursor correctly for the next selected repo. + let cursor: { label: string; level: number }[] = []; + const fullPaths = new Map(); + for (const group of groups) { + if (group.sectionPath !== undefined && group.sectionPath.length > 0) { + cursor = [...cursor.slice(0, group.sectionPath[0].level), ...group.sectionPath]; + } + if (cursor.length > 0) + fullPaths.set( + group, + cursor.map((p) => p.label), + ); + } + const results = groups .filter((g) => g.repoSelected) .map((group) => { - const base = { repo: group.repoFullName }; + const base: { repo: string; section?: string[] } = { repo: group.repoFullName }; + const path = fullPaths.get(group); + if (path !== undefined) base.section = path; if (outputType === "repo-only") return base; const matches = selectedMatches(group).map((m) => { const seg = m.textMatches[0]?.matches[0]; @@ -342,7 +397,12 @@ export function buildOutput( outputType: OutputType = "repo-and-matches", extraOptions: Pick< ReplayOptions, - "includeArchived" | "excludeTemplates" | "groupByTeamPrefix" | "regexHint" | "pickTeams" + | "includeArchived" + | "excludeTemplates" + | "groupByTeamPrefix" + | "consolidateTeamSections" + | "regexHint" + | "pickTeams" > = {}, ): string { const options: ReplayOptions = { format, outputType, ...extraOptions }; diff --git a/src/types.ts b/src/types.ts index 491333e..32398f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,15 @@ export interface RepoGroup { /** When set, this repo is the first entry of a new team section with this * label. Consumed by `buildRows` to emit a preceding section-header row. */ sectionLabel?: string; + /** When set (by `flattenTeamHierarchy`), this repo is the first entry of + * one or more new nested section headings — one entry per heading, each + * with its own nesting `level`. Only the headings that changed since the + * previous leaf are listed (siblings under an unchanged ancestor don't + * repeat that ancestor's heading). Consumers that need the *full* current + * path (e.g. JSON output) should maintain a running cursor: replace + * `cursor.slice(0, sectionPath[0].level)` with `sectionPath` whenever it + * is set. */ + sectionPath?: { label: string; level: number }[]; /** When set, this repo was moved from a combined section via --pick-team or * interactive pick. Stores the original combined label (e.g. "squad-a + squad-b") * so future split mode can identify it and offer to re-assign. */