Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import {
applyTeamPick,
consolidateTeamHierarchy,
flattenTeamHierarchy,
flattenTeamSections,
groupByTeamHierarchy,
groupByTeamPrefix,
Expand Down Expand Up @@ -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", () => {
Expand Down
62 changes: 62 additions & 0 deletions src/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Comment thread
shouze marked this conversation as resolved.
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 ─────────────────────────────────────────────────────────

/**
Expand Down
149 changes: 149 additions & 0 deletions src/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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");
});
});
Loading
Loading