Skip to content

Support --pick-team across all hierarchy levels - #189

Open
shouze wants to merge 2 commits into
feat/team-hierarchy-tuifrom
feat/team-hierarchy-pick-team
Open

Support --pick-team across all hierarchy levels#189
shouze wants to merge 2 commits into
feat/team-hierarchy-tuifrom
feat/team-hierarchy-pick-team

Conversation

@shouze

@shouze shouze commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds --pick-team support at every depth of a groupByTeamHierarchy tree,
building on #177-#180. A bare section label is no longer unique across a
hierarchy tree (e.g. "other" can appear under multiple parents), so this
introduces path-addressed equivalents of the existing flat pick/undo/move
operations.

  • src/group.ts: rebuildTeamHierarchy (inverse of flattenTeamHierarchy),
    plus tree-aware applyTeamPickInTree, undoSectionPickInTree,
    moveRepoToSectionInTree, undoPickedRepoInTree, and
    findCombinedSectionPaths. These address a section by its full
    root-to-node path; the path joined with " > " is used wherever a single
    string key is needed (pickedFrom, confirmedPicks). For a top-level
    (depth-1) path this is byte-for-byte equivalent to the existing flat
    functions (covered by parity tests).
  • src/render/rows.ts: getSectionPath(rows, rowIndex) reconstructs a
    section row's full ancestor path by scanning backward through the flat
    rows list for the most recent row at each decreasing sectionLevel.
  • src/tui.ts: pick mode (p) now captures the row's full sectionPath;
    the confirm/undo/re-pick handlers branch to the tree-aware functions when
    the path has 2+ segments, and keep using the existing flat functions
    unchanged for depth-1 sections — no behavior change for any current (flat)
    CLI invocation.
  • src/render.ts: the pick-mode section match now compares the full path
    instead of the bare label, so the same label at a different tree branch
    can't be targeted by mistake.

Bug found & fixed along the way

While wiring this up I hit a runtime crash entering pick mode:
getSectionPath was referenced in render.ts but only re-exported
(export { getSectionPath } from "./render/rows.ts"), never locally
import-ed — a re-export doesn't create a local binding, so calling it in
the same file throws ReferenceError at runtime. Bun's bundler doesn't
type-check, so this wasn't caught by bun run build.ts, and no existing
test exercised renderGroups with teamPickMode active at all. Fixed the
import and added regression tests that actually set teamPickMode (flat and
hierarchical, including a same-label-different-path case).

Also fixed an edge case in updateSiblingsAtPath (the shared tree-navigation
helper): if moving/undoing the last repo under an ancestor left that
ancestor with no groups and no children, it would get pruned entirely,
making the branch unreachable for a subsequent create — a missing ancestor
is now recreated (only when the operation actually produces something under
it, so a genuinely-unmatched path is still a true no-op).

CLI parsing/registration of hierarchy-aware --pick-team paths is
intentionally out of scope here — tracked by #182.

Closes #181

How to test

bun test src/group.test.ts src/render.test.ts

24 new group.test.ts tests cover: rebuildTeamHierarchy round-trips,
applyTeamPickInTree/undoSectionPickInTree/moveRepoToSectionInTree/
undoPickedRepoInTree (flat parity + nested cases + no-ops + the
missing-ancestor edge case), and findCombinedSectionPaths. New
render.test.ts tests cover the pick-mode section bar (flat, hierarchical
match, hierarchical non-match) that would have caught the getSectionPath
crash.

Validation

  • bun test (944 pass)
  • bun run lint
  • bun run format:check
  • bun run knip
  • bun run build.ts
  • bunx tsc --noEmit sanity check on all touched files (zero new errors;
    pre-existing unrelated tsc findings in render.ts/tui.ts confirmed
    present on main already, not part of this PR)

@shouze
shouze marked this pull request as ready for review August 23, 2026 21:10
Copilot AI lite review requested due to automatic review settings August 23, 2026 21:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds path-aware --pick-team support for hierarchical team sections, including tree operations, TUI integration, rendering, and tests.

Changes:

  • Adds hierarchy reconstruction and path-based pick, move, undo, and lookup helpers.
  • Updates TUI and rendering to track full section paths.
  • Adds regression and parity tests for flat and nested hierarchies.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Reviewed scope
src/tui.ts Hierarchical pick and re-pick handling
src/render/rows.ts Section ancestor-path reconstruction
src/render.ts Full-path pick-mode matching
src/render.test.ts Pick-mode rendering tests
src/group.ts Tree reconstruction and path-aware operations
src/group.test.ts Hierarchical operation tests
Suppressed comments (8)

src/group.ts:615

  • A TeamSection may have both direct groups and nested children, and a combined node can be a parent when a repo has multiple matches at this depth plus a deeper-chain match. Removing that node from remaining drops its entire child subtree (even when reposToMove is empty), so picking that combined header can silently lose descendant repos. Preserve/reparent the child tree while moving/tagging its repos, or explicitly reject picks on non-leaf combined nodes.
    const reposToMove = siblings[idx].groups.map((g) => ({ ...g, pickedFrom: pathKey }));
    const remaining = siblings.filter((_, i) => i !== idx);

src/group.ts:548

  • When a nested target section does not already exist, this helper creates it without a level. flattenTeamHierarchy then defaults that node to level 0, so a nested re-pick or undo renders the new section at the root and reconstructs an incorrect sectionPath. Assign the new node the depth implied by parentPath (while preserving the flat top-level shape).
    const newSection: TeamSection = { label, groups: repos };

src/group.ts:577

  • When a node keeps direct groups but all of its children are removed, children is set to an empty array because [] is truthy. This violates the TeamSection invariant that children is present only when non-empty and can make consumers treat a leaf as a parent; omit the property for an empty result.
      const children = node.children
        ? removeMatchingRepos(node.children, predicate, collected)
        : undefined;
      return { ...node, groups: kept, ...(children ? { children } : {}) };

src/group.ts:451

  • This new path-aware behavior stores "ancestor > combined" in RepoGroup.pickedFrom, but the shared field documentation still describes the value as only the bare combined label. Update that public contract so future consumers do not parse or display hierarchical values incorrectly.
// joined with `" > "`, is stored in `pickedFrom` so `undoSectionPickInTree`
// can find every repo picked from that exact section later. For a top-level
// section (`path.length === 1`), this is behaviourally identical to the flat
// `applyTeamPick` / `undoSectionPick` (same joined string as the bare label).

src/group.ts:478

  • rebuildTeamHierarchy is used for every tree pick/re-pick, and spreading the entire children array for each sibling makes reconstruction quadratic in the number of siblings. Since these nodes are newly allocated inside this function, append the child in place (or accumulate children separately) so rebuilding a wide hierarchy remains linear.
          parent.children = [...(parent.children ?? []), node];

src/render/rows.ts:158

  • While pick mode is active, renderGroups calls getSectionPath for every section row on each redraw. Scanning backward through all preceding rows for each level-1 sibling makes a wide hierarchy O(rows²) per redraw, so cycling candidates can become increasingly slow. Cache or carry the full path while building rows rather than rescanning the prefix of the list for every header.
  for (let i = rowIndex - 1; i >= 0 && neededLevel >= 0; i--) {
    const r = rows[i];
    if (
      r.type === "section" &&
      r.sectionLabel !== undefined &&

src/tui.ts:540

  • The same representation check misses top-level hierarchy picks during re-pick: those picks intentionally store the bare combined label in pickedFrom, but the flattened groups still carry sectionPath. After a top-level hierarchy pick, this branch calls the flat mover, whose rebuildTeamSections cannot see the hierarchy sections, so re-pick becomes a no-op. Use the hierarchy marker in groups in addition to the separator check (and apply the same correction to the undo branch below).
        if (pickedFrom.includes(" > ")) {

src/tui.ts:560

  • Top-level hierarchy picks also have a bare pickedFrom, so this condition selects undoSectionPick even though the current groups array is in the sectionPath representation. The flat rebuild then fails to find the picked repos and the confirmedPicks entry is removed without restoring the section. Branch on the presence of hierarchy metadata here as well as on the path separator.
          if (combinedLabel.includes(" > ")) {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/group.ts
Comment on lines +511 to +529
function updateSiblingsAtPath(
sections: TeamSection[],
parentPath: string[],
updater: (siblings: TeamSection[]) => TeamSection[],
): TeamSection[] {
if (parentPath.length === 0) return updater(sections);

const [head, ...rest] = parentPath;
const idx = sections.findIndex((s) => s.label === head);
if (idx === -1) {
const children = updateSiblingsAtPath([], rest, updater);
// Only materialize the missing ancestor if the update actually produced
// something inside it — otherwise this is a genuine no-op (e.g. a
// typo'd path) and adding an empty node here would pollute the tree.
return children.length === 0 ? sections : [...sections, { label: head, groups: [], children }];
}

const updatedChildren = updateSiblingsAtPath(sections[idx].children ?? [], rest, updater);
return sections.map((s, i) => (i === idx ? { ...s, children: updatedChildren } : s));
Comment thread src/group.ts
Comment on lines +528 to +529
const updatedChildren = updateSiblingsAtPath(sections[idx].children ?? [], rest, updater);
return sections.map((s, i) => (i === idx ? { ...s, children: updatedChildren } : s));
Comment thread src/tui.ts
Comment on lines +478 to +485
if (teamPickMode.sectionPath.length > 1) {
const sections = rebuildTeamHierarchy(groups);
const updated = applyTeamPickInTree(sections, teamPickMode.sectionPath, chosen);
groups = flattenTeamHierarchy(updated);
} else {
const sections = rebuildTeamSections(groups);
const updated = applyTeamPick(sections, teamPickMode.sectionLabel, chosen);
groups = flattenTeamSections(updated);
@github-actions

Copy link
Copy Markdown

Coverage after merging feat/team-hierarchy-pick-team into feat/team-hierarchy-tui will be

96.75%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts93.20%100%93.75%93.13%101–103, 65, 73, 86–87, 91–92
   api.ts94.74%100%100%94.07%340–344, 405, 422, 63–69
   cache.ts94.67%100%100%94.29%139–141, 39
   completions.ts99.42%100%100%99.37%270
   group.ts99.11%100%97.89%99.36%546, 552, 631
   output.ts99.37%100%95.83%99.66%84
   regex.ts99.39%100%100%99.34%329
   render.ts90.75%100%88.24%90.85%173, 197–202, 204–206, 208–209, 230, 423–424, 522–526, 556–563, 565–573, 575–578
   scroll-cooldown.ts100%100%100%100%
   upgrade.ts88.38%100%94.44%87.89%128, 131, 133, 153, 167–168, 188–195, 198–204, 209, 214, 250–253
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.63%100%90.40%99.31%284–285
   layout-constants.ts100%100%100%100%
   mouse-hit.ts100%100%100%100%
   mouse.ts100%100%100%100%
   rows.ts99.42%100%100%99.38%239
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%
   team-pick.ts100%100%100%100%
   terminal.ts100%100%100%100%

shouze added 2 commits August 24, 2026 02:13
- Added path-addressed pick/undo/move functions in group.ts:
  rebuildTeamHierarchy (inverse of flattenTeamHierarchy), plus tree-aware
  applyTeamPickInTree, undoSectionPickInTree, moveRepoToSectionInTree,
  undoPickedRepoInTree, and findCombinedSectionPaths — since a bare
  section label is no longer unique across a groupByTeamHierarchy tree
  (e.g. "other" can appear under multiple parents), these address a
  section by its full root-to-node path, joined with " > " wherever a
  single string key is needed (pickedFrom, confirmedPicks).
- Added getSectionPath (render/rows.ts) to reconstruct a section row's
  full ancestor path from the flat rows list, by scanning backward for
  the most recent row at each decreasing sectionLevel.
- Wired tui.ts: pick mode (p) captures the row's full sectionPath;
  confirm/undo/re-pick branch to the tree-aware functions when the path
  has 2+ segments, and keep using the existing flat functions unchanged
  for top-level (depth-1) sections — byte-for-byte the same behavior as
  before for every current (flat) CLI invocation.
- render.ts's pick-mode section match now compares the full path instead
  of the bare label, so the same label at a different tree branch isn't
  targeted by mistake.

Fix: getSectionPath was referenced in render.ts without a local import
(only re-exported), causing a runtime ReferenceError when entering pick
mode — added the missing import and a regression test that actually
exercises renderGroups with teamPickMode active (previously untested).

CLI parsing/registration of hierarchy-aware --pick-team paths is
intentionally out of scope here — tracked by issue #182.
applyTeamPickInTree only carried the picked combined section's own
(often empty) groups over to the chosen sibling, silently discarding
its children — any top-level combined section already subdivided by a
further chain level (e.g. --group-by-team-prefix gamme-/squad-, where
2 gamme- teams overlap on repos that also matched a squad- team) lost
every repo nested underneath as soon as it was picked.

Fixed by recursively tagging and carrying over the whole picked
subtree (own groups AND children) into the target section, merging
children when the target already exists.

Reported with: --group-by-team-prefix gamme-/squad-,chapter- and
--pick-team 'gamme-lead-mobile + gamme-lead-mobile-security-p1'=gamme-lead-mobile
@shouze
shouze force-pushed the feat/team-hierarchy-pick-team branch from 2605932 to cff447a Compare August 24, 2026 00:14
@github-actions

Copy link
Copy Markdown

Coverage after merging feat/team-hierarchy-pick-team into feat/team-hierarchy-tui will be

96.78%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts93.20%100%93.75%93.13%101–103, 65, 73, 86–87, 91–92
   api.ts94.74%100%100%94.07%340–344, 405, 422, 63–69
   cache.ts94.67%100%100%94.29%139–141, 39
   completions.ts99.42%100%100%99.37%270
   group.ts99.11%100%97.89%99.36%546, 552, 631
   output.ts99.37%100%95.83%99.66%84
   regex.ts99.39%100%100%99.34%329
   render.ts90.99%100%88.24%91.09%177, 201–206, 208–210, 212–213, 234, 427–428, 528–532, 562–569, 571–579, 581–584
   scroll-cooldown.ts100%100%100%100%
   style.ts100%100%100%100%
   upgrade.ts88.38%100%94.44%87.89%128, 131, 133, 153, 167–168, 188–195, 198–204, 209, 214, 250–253
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.63%100%90.40%99.31%284–285
   layout-constants.ts100%100%100%100%
   mouse-hit.ts100%100%100%100%
   mouse.ts100%100%100%100%
   rows.ts99.42%100%100%99.38%239
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%
   team-pick.ts100%100%100%100%
   terminal.ts100%100%100%100%

1 similar comment
@github-actions

Copy link
Copy Markdown

Coverage after merging feat/team-hierarchy-pick-team into feat/team-hierarchy-tui will be

96.78%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts93.20%100%93.75%93.13%101–103, 65, 73, 86–87, 91–92
   api.ts94.74%100%100%94.07%340–344, 405, 422, 63–69
   cache.ts94.67%100%100%94.29%139–141, 39
   completions.ts99.42%100%100%99.37%270
   group.ts99.11%100%97.89%99.36%546, 552, 631
   output.ts99.37%100%95.83%99.66%84
   regex.ts99.39%100%100%99.34%329
   render.ts90.99%100%88.24%91.09%177, 201–206, 208–210, 212–213, 234, 427–428, 528–532, 562–569, 571–579, 581–584
   scroll-cooldown.ts100%100%100%100%
   style.ts100%100%100%100%
   upgrade.ts88.38%100%94.44%87.89%128, 131, 133, 153, 167–168, 188–195, 198–204, 209, 214, 250–253
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.63%100%90.40%99.31%284–285
   layout-constants.ts100%100%100%100%
   mouse-hit.ts100%100%100%100%
   mouse.ts100%100%100%100%
   rows.ts99.42%100%100%99.38%239
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%
   team-pick.ts100%100%100%100%
   terminal.ts100%100%100%100%

@shouze shouze self-assigned this Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[5/7] --pick-team / re-pick across all hierarchy levels

2 participants