Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### closedloop-core v1.5.3

#### Fixed
- `prd-dependency-graph` draws every BLOCKS link between two tickets the graph shows, not only links that touch an in-scope ticket or lead into an outside blocker. A ticket the scope blocks that also blocks another such ticket now gets that arrow, so hovering or pinning a ticket shows all of its drawn blockers. With one layer of outside blockers, links between those blockers and from them to downstream tickets are drawn too. New pages, `--once`, the refresh button and the outside-blockers option all build links the same way. The links of each drawn ticket not already read during the walk are read once, and no ticket is added to the graph.

### vibe v1.0.85

#### Added
Expand Down
2 changes: 1 addition & 1 deletion plugins/closedloop-core/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "closedloop-core",
"description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring",
"version": "1.5.2",
"version": "1.5.3",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}
}
2 changes: 1 addition & 1 deletion plugins/closedloop-core/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "closedloop-core",
"version": "1.5.2",
"version": "1.5.3",
"description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"},
"homepage": "https://github.com/closedloop-ai/claude-plugins",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Your job is to pick the scope and options, run them, name the columns, and check
- **Tickets outside the scope** are drawn dashed, and the legend reads "outside <scope>":
- tickets that in-scope tickets block are shown one layer deep;
- tickets that block the scope are followed upstream `outside_blockers_depth` layers (default 1; `"all"` follows the chain to its roots).
- **Links between tickets on the graph:** once the tickets above are chosen, every BLOCKS link between two of them is drawn, wherever they sit. A ticket that the scope blocks and that also blocks another such ticket gets that arrow, and it is part of both tickets' hover and pinned chains. These links never add a ticket to the graph.

## Steps

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ def fetch_graph(cfg, api):
Outside tickets that an in-scope ticket blocks are shown one layer deep.
Outside blockers are followed upstream `outside_blockers_depth` layers
(default 1; "all" follows the chain to its roots).

Once the drawn tickets are known, every BLOCKS link between two of them is drawn too, wherever
they sit (for example one downstream ticket blocking another). That pass reads the links of each
drawn ticket not already read during the walk, once, and adds links only, never tickets.
"""
source = cfg["refresh"]
app_base = source.get("app_issue_base", "")
Expand All @@ -255,40 +259,52 @@ def fetch_graph(cfg, api):
edges = set()
frontier = list(tickets) # level 0 is the scope; level n is n outside blockers up
level = 0
fetched = set()
links_of: dict[str, list] = {} # every BLOCKS list read, reused by the closing pass

def endpoints(link):
return (link.get("source") or {}).get("slug"), (link.get("target") or {}).get("slug")

with ThreadPoolExecutor(max_workers=8) as pool:
def read_links(slugs):
links_of.update(pool.map(lambda slug: (slug, api.links(slug, "BLOCKS", "both")), slugs))

while frontier:
fetched.update(frontier)
results = list(pool.map(lambda slug: (slug, api.links(slug, "BLOCKS", "both")), frontier))
read_links(frontier)
found = set()
for queried, link_list in results:
for queried in frontier:
in_scope = queried in tickets
for link in link_list:
src, dst = link.get("source") or {}, link.get("target") or {}
s, d = src.get("slug"), dst.get("slug")
for link in links_of[queried]:
s, d = endpoints(link)
if not s or not d:
continue
if not in_scope and d != queried:
continue # from an outside ticket, only follow what blocks it
if s not in tickets and s not in nodes and limit == 0:
continue # outside blockers turned off
for end, slug in ((src, s), (dst, d)):
for end, slug in ((link.get("source") or {}, s), (link.get("target") or {}, d)):
if slug not in nodes:
node = endpoint_node(end, app_base)
node["external"] = True
nodes[slug] = node
edges.add((s, d))
if s not in tickets and s not in fetched:
if s not in tickets and s not in links_of:
found.add(s)
level += 1
if limit is not None and level >= limit:
break
frontier = sorted(found)
produces = set()
root = scope_root(api, source, app_base, tickets)
if root and root["id"] not in nodes:
nodes[root["id"]] = root
produces = {(root["id"], t) for t in tickets}
produces = set()
root = scope_root(api, source, app_base, tickets)
if root and root["id"] not in nodes:
nodes[root["id"]] = root
produces = {(root["id"], t) for t in tickets}
# A link touching the root is also in the list of the ticket at its other end.
read_links(sorted(slug for slug in nodes if slug not in links_of and not nodes[slug].get("root")))
for link_list in links_of.values():
for link in link_list:
s, d = endpoints(link)
if s in nodes and d in nodes:
edges.add((s, d))
return nodes, edges, produces


Expand Down
3 changes: 3 additions & 0 deletions plugins/closedloop-core/tools/python/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def __init__(self):
self.projects = {"PRO-5": ["ISS-50", "ISS-51"]}
self.blocks = [("ISS-1", "ISS-2"), ("ISS-2", "ISS-3"), ("ISS-90", "ISS-3"), ("ISS-50", "ISS-51")]
self.calls = 0
self.link_queries: list[str] = []
self.bases: list[str] = []
self.keys: list[str] = []

Expand Down Expand Up @@ -81,6 +82,8 @@ def get(self, path, params):

def links(self, artifact, link_type, direction):
self.world.calls += 1
if link_type == "BLOCKS":
self.world.link_queries.append(artifact)
if link_type == "PRODUCES":
return [{"source": {"slug": artifact}, "target": self.world.end(s)}
for s in self.world.children.get(artifact, [])]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Every BLOCKS link between two tickets the graph draws is drawn, wherever those tickets sit.

PRD-7 has one ticket, A (ISS-10). A blocks X, Y and Z (ISS-11, ISS-12, ISS-13), which are outside
the scope, and Y blocks Z. B1 and B2 (ISS-20, ISS-21) block A from outside; B1 also blocks B2 and Y.
W (ISS-14) blocks Z and V (ISS-15) waits on Z; neither touches the scope, so neither is drawn.
"""

import re

import pytest
from conftest import embedded, new_graph, refresh_body

SCOPE_EDGES = {("ISS-10", "ISS-11"), ("ISS-10", "ISS-12"), ("ISS-10", "ISS-13"), ("ISS-12", "ISS-13")}
BLOCKER_EDGES = {("ISS-20", "ISS-10"), ("ISS-21", "ISS-10"), ("ISS-20", "ISS-21"), ("ISS-20", "ISS-12")}
LEGEND = re.compile(r"Arrow: A blocks B\. (\d+) BLOCKS links, all drawn\.")


@pytest.fixture
def world(graph):
api = graph.api
for slug, name in (("ISS-10", "Scope ticket"), ("ISS-11", "Downstream X"), ("ISS-12", "Downstream Y"),
("ISS-13", "Downstream Z"), ("ISS-14", "Unrelated W"), ("ISS-15", "After Z"),
("ISS-20", "Blocker one"), ("ISS-21", "Blocker two")):
api.tickets[slug] = (name, "Lee", "TODO")
api.children["PRD-7"] = ["ISS-10"]
api.blocks.extend([("ISS-10", "ISS-11"), ("ISS-10", "ISS-12"), ("ISS-10", "ISS-13"), ("ISS-12", "ISS-13"),
("ISS-14", "ISS-13"), ("ISS-13", "ISS-15"),
("ISS-20", "ISS-10"), ("ISS-21", "ISS-10"), ("ISS-20", "ISS-21"), ("ISS-20", "ISS-12")])
return graph


def edges_of(cfg):
return {(e["from"], e["to"]) for e in cfg["edges"]}


def upstream(edges, ticket):
"""The chain a pinned ticket lights up in purple: everything that blocks it, transitively."""
seen, todo = set(), [ticket]
while todo:
current = todo.pop()
for s, d in edges:
if d == current and s not in seen:
seen.add(s)
todo.append(s)
return seen


def legend_count(text):
match = LEGEND.search(text)
assert match, "the legend states the BLOCKS link count"
return int(match.group(1))


def drawn_edges(text):
"""BLOCKS arrows in the page; the page's hover chains are built from these paths."""
return set(re.findall(r'<path class="edge(?: open)?" data-from="([^"]+)" data-to="([^"]+)"', text))


@pytest.mark.parametrize(("depth", "expected"), [("1", SCOPE_EDGES | BLOCKER_EDGES),
("all", SCOPE_EDGES | BLOCKER_EDGES),
("0", SCOPE_EDGES)])
def test_links_between_drawn_tickets_outside_the_scope_are_drawn(world, tmp_path, depth, expected):
page = tmp_path / "graph.html"

new_graph(world, "PRD-7", output=page, outside_depth=depth)

cfg = embedded(world, page, "prd-7")
text = page.read_text(encoding="utf-8")
edges = edges_of(cfg)
assert edges == expected
assert drawn_edges(text) == edges
assert {"ISS-10", "ISS-12"} <= upstream(edges, "ISS-13")
assert legend_count(text) == len(edges)
tickets = {n["id"] for n in cfg["nodes"] if not n.get("root")}
# Only links are added; the tickets drawn are the ones the scope reaches, as before.
assert "ISS-14" not in tickets and "ISS-15" not in tickets
assert tickets == {x for e in expected for x in e}
# Each drawn ticket's links are read once, and no ticket outside the graph is queried.
assert sorted(world.api.link_queries) == sorted(tickets)


def test_a_refresh_from_the_page_draws_a_new_link_between_downstream_tickets(world, helper, tmp_path):
world.api.blocks.remove(("ISS-12", "ISS-13"))
page = tmp_path / "graph.html"
new_graph(world, "PRD-7", output=page)
assert ("ISS-12", "ISS-13") not in edges_of(embedded(world, page, "prd-7"))
world.api.blocks.append(("ISS-12", "ISS-13"))

resp = helper("POST", "/v2/refresh", refresh_body(page, graph_id="prd-7", scope="PRD-7"))

assert resp.status == 200
cfg = embedded(world, page, "prd-7")
text = page.read_text(encoding="utf-8")
assert edges_of(cfg) == SCOPE_EDGES | BLOCKER_EDGES
assert drawn_edges(text) == edges_of(cfg)
assert "ISS-12" in upstream(edges_of(cfg), "ISS-13")
assert "new link: ISS-12 blocks ISS-13" in cfg["last_refresh"]["changes"]
assert legend_count(text) == len(cfg["edges"])


def test_switching_on_the_full_chain_keeps_links_between_drawn_tickets(world, helper, tmp_path):
page = tmp_path / "graph.html"
new_graph(world, "PRD-7", output=page, outside_depth="0")
assert edges_of(embedded(world, page, "prd-7")) == SCOPE_EDGES

resp = helper("POST", "/v2/refresh", refresh_body(page, graph_id="prd-7", scope="PRD-7", outside="all"))

assert resp.status == 200
cfg = embedded(world, page, "prd-7")
assert edges_of(cfg) == SCOPE_EDGES | BLOCKER_EDGES
assert "new link: ISS-20 blocks ISS-12" in cfg["last_refresh"]["changes"]
Original file line number Diff line number Diff line change
Expand Up @@ -244,17 +244,20 @@ def cached_helper(version):
encoding="utf-8")
return script

old_script = cached_helper("1.5.1")
old_script = cached_helper("0.0.1")
graph.server.install_launcher()
new_script = cached_helper("1.5.3")
# Newer than this checkout's own version, which the launcher prefers on a tie.
source_version = json.loads((SCRIPTS.parents[2] / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))
newer = f"{int(source_version['version'].split('.')[0]) + 1}.0.0"
new_script = cached_helper(newer)
shutil.rmtree(old_script.parents[3])
launcher_env = dict(os.environ, PATH=os.path.dirname(sys.executable) + os.pathsep + os.environ["PATH"])

result = subprocess.run([str(graph.render.LAUNCHER), "--ensure"], capture_output=True, text=True, timeout=30,
env=launcher_env)

assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"version": "1.5.3", "args": ["--ensure"]}
assert json.loads(result.stdout) == {"version": newer, "args": ["--ensure"]}
assert new_script.exists()

shutil.rmtree(new_script.parents[3])
Expand Down
Loading