1"""ll-issues clusters: Render issue relationship clusters as box diagrams."""

2

3 from __future__ import annotations

4

5 import argparse

6 from collections import deque

7 from typing import TYPE_CHECKING

8

9 from little_loops.cli.output import colorize, print_json

10

11 if TYPE_CHECKING:

12 from little_loops.config import BRConfig

13 from little_loops.issue_parser import IssueInfo

14

15# ANSI color codes per relationship type

16 EDGE_COLOR: dict[str, str] = {

17 "blocks": "31", # red

18 "blocked_by": "33", # yellow

19 "parent": "34", # blue

20 "sibling": "36", # cyan

21 "depends_on": "35", # magenta

22 "relates_to": "37", # white/dim

23}

24

25_BOX_HEIGHT = 4 # top border + 2 content lines + bottom border

26_GAP_HEIGHT = 2 # rows between boxes for arrow drawing

27_BOX_MARGIN = 2 # left-margin column offset

28_MAX_BOX_WIDTH = 60

29

30# Edge type sets for --edges aliases

31_ALL_EDGE_TYPES = frozenset({"blocked_by", "blocks", "depends_on", "relates_to", "parent"})

32_BLOCKING_EDGE_TYPES = frozenset({"blocked_by", "blocks"})

33_HARD_EDGE_TYPES = frozenset({"blocked_by", "blocks", "depends_on"})

34

35# Active status set for --status=active default

36_ACTIVE_STATUSES = frozenset({"open", "in_progress", "blocked"})

37

38# Priority order when two relationships describe the same pair (lower = higher priority)

39_EDGE_PRIORITY: dict[str, int] = {

40 "blocked_by": 0,

41 "blocks": 1,

42 "parent": 2,

43 "depends_on": 3,

44 "relates_to": 4,

45}

46

47

48 def _resolve_edge_types(edges_arg: str) -> set[str]:

49 """Resolve --edges argument to a set of edge type strings."""

50 if edges_arg == "all":

51 return set(_ALL_EDGE_TYPES)

52 if edges_arg == "blocking":

53 return set(_BLOCKING_EDGE_TYPES)

54 if edges_arg == "hard":

55 return set(_HARD_EDGE_TYPES)

56 return set(edges_arg.split(","))

57

58

59 def _resolve_status_set(status_arg: str) -> set[str]:

60 """Resolve --status argument to a set of canonical status strings."""

61 if status_arg == "active":

62 return set(_ACTIVE_STATUSES)

63 if status_arg == "+deferred":

64 return set(_ACTIVE_STATUSES) | {"deferred"}

65 if status_arg == "all":

66 return {"open", "in_progress", "blocked", "done", "deferred"}

67 return set(status_arg.split(","))

68

69

70 def _build_neighbour_map(issues: list[IssueInfo], edge_types: set[str]) -> dict[str, set[str]]:

71 """Build undirected neighbour map from IssueInfo for connectivity BFS.

72

73 Only connects issues to other issues present in the loaded list.

74 """

75 issue_ids = {i.issue_id for i in issues}

76 neighbours: dict[str, set[str]] = {i.issue_id: set() for i in issues}

77

78 for issue in issues:

79 iid = issue.issue_id

80 targets: list[str] = []

81

82 if "blocked_by" in edge_types:

83 targets.extend(issue.blocked_by)

84 if "blocks" in edge_types:

85 targets.extend(issue.blocks)

86 if "depends_on" in edge_types:

87 targets.extend(issue.depends_on)

88 if "relates_to" in edge_types:

89 targets.extend(issue.relates_to)

90 if "parent" in edge_types and issue.parent:

91 targets.append(issue.parent)

92

93 for target in targets:

94 if target in issue_ids:

95 neighbours[iid].add(target)

96 neighbours[target].add(iid)

97

98 return neighbours

99

100

101 def _get_components(neighbours: dict[str, set[str]]) -> list[list[str]]:

102 """BFS over undirected neighbour map to find connected components.

103

104 Returns components sorted by size descending.

105 """

106 visited: set[str] = set()

107 components: list[list[str]] = []

108

109 for node in sorted(neighbours):

110 if node in visited:

111 continue

112 component: list[str] = []

113 queue: deque[str] = deque([node])

114 while queue:

115 current = queue.popleft()

116 if current in visited:

117 continue

118 visited.add(current)

119 component.append(current)

120 for neighbor in sorted(neighbours.get(current, set())):

121 if neighbor not in visited:

122 queue.append(neighbor)

123 components.append(component)

124

125 return sorted(components, key=len, reverse=True)

126

127

128 def _topo_sort_cluster(

129 cluster_ids: list[str],

130 blocked_by: dict[str, set[str]],

131) -> tuple[list[str], bool]:

132 """Topological sort (Kahn's) scoped to this cluster.

133

134 Returns (sorted_ids, has_cycle). Nodes in cycles are appended in sorted

135 order after the acyclic prefix so the caller always gets a full list.

136 """

137 cluster_set = set(cluster_ids)

138 in_degree: dict[str, int] = dict.fromkeys(cluster_ids, 0)

139 adj: dict[str, list[str]] = {id_: [] for id_ in cluster_ids}

140

141 for id_ in cluster_ids:

142 for dep in sorted(blocked_by.get(id_, set()) & cluster_set):

143 in_degree[id_] += 1

144 adj[dep].append(id_)

145

146 queue: deque[str] = deque(sorted(id_ for id_, deg in in_degree.items() if deg == 0))

147 result: list[str] = []

148

149 while queue:

150 node = queue.popleft()

151 result.append(node)

152 for dep in sorted(adj[node]):

153 in_degree[dep] -= 1

154 if in_degree[dep] == 0:

155 queue.append(dep)

156

157 has_cycle = len(result) < len(cluster_ids)

158 if has_cycle:

159 remaining = sorted(id_ for id_ in cluster_ids if id_ not in set(result))

160 result.extend(remaining)

161

162 return result, has_cycle

163

164

165 def _cluster_edges(

166 cluster_ids: set[str],

167 issues: list[IssueInfo],

168 edge_types: set[str],

169) -> list[tuple[str, str, str]]:

170 """Return directed edges within a cluster as (from_id, to_id, relationship).

171

172 Deduplicates edges between the same pair, keeping the highest-priority

173 relationship type (blocked_by > blocks > parent > depends_on > relates_to).

174 """

175 issues_in_cluster = {i.issue_id: i for i in issues if i.issue_id in cluster_ids}

176 # frozenset key → (from_id, to_id, rel) for deduplication

177 best: dict[frozenset[str], tuple[str, str, str]] = {}

178

179 for iid in sorted(cluster_ids):

180 issue = issues_in_cluster.get(iid)

181 if not issue:

182 continue

183

184 candidates: list[tuple[str, str, str]] = []

185 if "blocked_by" in edge_types:

186 for t in sorted(issue.blocked_by):

187 if t in cluster_ids:

188 candidates.append((iid, t, "blocked_by"))

189 if "blocks" in edge_types:

190 for t in sorted(issue.blocks):

191 if t in cluster_ids:

192 candidates.append((iid, t, "blocks"))

193 if "depends_on" in edge_types:

194 for t in sorted(issue.depends_on):

195 if t in cluster_ids:

196 candidates.append((iid, t, "depends_on"))

197 if "relates_to" in edge_types:

198 for t in sorted(issue.relates_to):

199 if t in cluster_ids:

200 candidates.append((iid, t, "relates_to"))

201 if "parent" in edge_types and issue.parent and issue.parent in cluster_ids:

202 candidates.append((iid, issue.parent, "parent"))

203

204 for from_id, to_id, rel in candidates:

205 key: frozenset[str] = frozenset({from_id, to_id})

206 existing = best.get(key)

207 if existing is None or _EDGE_PRIORITY.get(rel, 99) < _EDGE_PRIORITY.get(

208 existing[2], 99

209 ):

210 best[key] = (from_id, to_id, rel)

211

212 return list(best.values())

213

214

215 def _render_cluster_diagram(

216 ordered_ids: list[str],

217 issues_map: dict[str, IssueInfo],

218 edge_map: dict[tuple[str, str], str],

219 box_width: int,

220) -> list[str]:

221 """Render a cluster as a vertical stack of box diagrams with arrows.

222

223 Uses _draw_box from cli/loop/layout.py as the box primitive.

224 Arrows are drawn between consecutive nodes; edge labels are appended

225 after the arrow row so ANSI escapes don't corrupt the character grid.

226 """

227 # Intentional cross-module import of a private primitive; _draw_box is

228 # reusable and has no FSM-specific logic when is_highlighted=False.

229 from little_loops.cli.loop.layout import _draw_box

230

231 n = len(ordered_ids)

232 grid_h = n * _BOX_HEIGHT + max(0, n - 1) * _GAP_HEIGHT

233 grid_w = box_width + _BOX_MARGIN * 2 + 2

234 grid: list[list[str]] = [[" "] * grid_w for _ in range(grid_h)]

235

236 center_col = _BOX_MARGIN + box_width // 2

237 box_start: dict[str, int] = {}

238 avail = box_width - 4 # interior width minus side margins

239

240 for i, issue_id in enumerate(ordered_ids):

241 row = i * (_BOX_HEIGHT + _GAP_HEIGHT)

242 issue = issues_map[issue_id]

243 title = issue.title if len(issue.title) <= avail else issue.title[: avail - 1] + "…"

244 content = [f"[{issue.priority}] {issue_id}", title]

245 _draw_box(grid, row, _BOX_MARGIN, box_width, _BOX_HEIGHT, content, False, "0")

246 box_start[issue_id] = row

247

248 # Annotate gap rows with arrow characters and colored edge labels.

249 # Only draw connectors when a real edge exists between consecutive nodes.

250 # Arrow head direction matches the semantic edge direction, not the

251 # topo-sort layout order: ▼ when top→bottom matches the edge, ▲ when

252 # the edge goes bottom→top (reverse of the visual stack).

253 arrow_labels: dict[int, str] = {}

254 for i in range(n - 1):

255 a_id = ordered_ids[i]

256 b_id = ordered_ids[i + 1]

257 gap_row = box_start[a_id] + _BOX_HEIGHT

258

259 rel = edge_map.get((a_id, b_id)) or edge_map.get((b_id, a_id))

260 if rel:

261 if gap_row < grid_h:

262 grid[gap_row][center_col] = "│"

263 if gap_row + 1 < grid_h:

264 forward = (a_id, b_id) in edge_map

265 grid[gap_row + 1][center_col] = "▼" if forward else "▲"

266 color = EDGE_COLOR.get(rel, "37")

267 arrow_labels[gap_row] = colorize(f" {rel}", color)

268

269 # Convert grid to string lines, appending annotations after arrow rows

270 lines: list[str] = []

271 for r, row_chars in enumerate(grid):

272 line = "".join(row_chars).rstrip()

273 if r in arrow_labels:

274 line += arrow_labels[r]

275 lines.append(line)

276

277 while lines and not lines[-1].strip():

278 lines.pop()

279

280 # Append annotations for skip-level edges (non-consecutive in topo order).

281 pos = {id_: i for i, id_ in enumerate(ordered_ids)}

282 skip_edges = [(f, t, r) for (f, t), r in sorted(edge_map.items()) if abs(pos[t] - pos[f]) > 1]

283 if skip_edges:

284 lines.append("")

285 for src, dst, rel in skip_edges:

286 color = EDGE_COLOR.get(rel, "37")

287 lines.append(f" {src} {colorize('→', color)} {dst} ({rel})")

288

289 return lines

290

291

292 def cmd_clusters(config: BRConfig, args: argparse.Namespace) -> int:

293 """Render issue relationship clusters as box diagrams.

294

295 Args:

296 config: Project configuration (provides issue directories and CLI settings)

297 args: Parsed CLI args (include_orphans, min_connections, json, edges, status)

298

299 Returns:

300 Exit code (0 = success)

301 """

302 from little_loops.cli.output import terminal_width

303 from little_loops.issue_parser import find_issues

304

305 edges_arg: str = getattr(args, "edges", "all")

306 status_arg: str = getattr(args, "status", "active")

307

308 edge_types = _resolve_edge_types(edges_arg)

309 status_set = _resolve_status_set(status_arg)

310

311 issues = find_issues(config, status_filter=status_set)

312 if not issues:

313 print("No active issues found.")

314 return 0

315

316 neighbours = _build_neighbour_map(issues, edge_types)

317 issues_map = {issue.issue_id: issue for issue in issues}

318 components = _get_components(neighbours)

319

320 include_orphans: bool = getattr(args, "include_orphans", False)

321 if not include_orphans:

322 components = [c for c in components if len(c) > 1]

323

324 min_conn: int = getattr(args, "min_connections", 0) or 0

325 if min_conn > 0:

326

327 def _max_degree(comp: list[str]) -> int:

328 return max(len(neighbours.get(id_, set())) for id_ in comp)

329

330 components = [c for c in components if _max_degree(c) >= min_conn]

331

332 if not components:

333 all_components = _get_components(neighbours)

334 if all(len(c) == 1 for c in all_components):

335 print("No issue relationships found. Use --include-orphans to show isolated issues.")

336 else:

337 print("No clusters match the specified filters.")

338 return 0

339

340 # JSON mode: emit structured data, no diagram rendering

341 if getattr(args, "json", False):

342 output = []

343 for idx, comp in enumerate(components, 1):

344 comp_set = set(comp)

345 edges = _cluster_edges(comp_set, issues, edge_types)

346 output.append(

347 {

348 "cluster_index": idx,

349 "issue_count": len(comp),

350 "issues": [

351 {

352 "id": id_,

353 "priority": issues_map[id_].priority,

354 "title": issues_map[id_].title,

355 }

356 for id_ in sorted(comp)

357 ],

358 "edges": [{"from": f, "to": t, "relationship": r} for f, t, r in edges],

359 }

360 )

361 print_json(output)

362 return 0

363

364 # Text rendering

365 width = terminal_width()

366 box_w = max(20, min(_MAX_BOX_WIDTH, width - _BOX_MARGIN * 2 - 4))

367 total_issues = sum(len(c) for c in components)

368

369 # Build blocked_by map from IssueInfo for topo sort ordering

370 blocked_by_map: dict[str, set[str]] = {

371 issue.issue_id: set(issue.blocked_by) for issue in issues

372 }

373

374 for idx, comp in enumerate(components, 1):

375 comp_set = set(comp)

376 edges = _cluster_edges(comp_set, issues, edge_types)

377 edge_map: dict[tuple[str, str], str] = {(f, t): r for f, t, r in edges}

378

379 ordered, has_cycle = _topo_sort_cluster(comp, blocked_by_map)

380

381 sep = "─" * 3

382 n_issues = len(comp)

383 noun = "issue" if n_issues == 1 else "issues"

384 print(f"{sep} Cluster {idx} ({n_issues} {noun}) {sep}")

385 if has_cycle:

386 print("⚠ cycle detected — using fallback layout")

387

388 diagram_lines = _render_cluster_diagram(ordered, issues_map, edge_map, box_w)

389 print("\n".join(diagram_lines))

390 print()

391

392 n_clusters = len(components)

393 cluster_noun = "cluster" if n_clusters == 1 else "clusters"

394 issue_noun = "issue" if total_issues == 1 else "issues"

395 print(f"{n_clusters} {cluster_noun}, {total_issues} {issue_noun} total")

396 return 0