@stylusnexus/work-plan 2026.7.13 → 2026.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/brief.py +47 -7
- package/skills/work-plan/commands/doctor.py +529 -0
- package/skills/work-plan/commands/export.py +97 -24
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/github_state.py +54 -0
- package/skills/work-plan/lib/notes_vcs.py +21 -9
- package/skills/work-plan/lib/tracks.py +17 -3
- package/skills/work-plan/tests/test_brief_batch_fetch.py +140 -0
- package/skills/work-plan/tests/test_config.py +59 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export_command.py +161 -8
- package/skills/work-plan/tests/test_github_state.py +143 -1
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_tracks.py +38 -1
- package/skills/work-plan/work_plan.py +20 -0
|
@@ -59,7 +59,8 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
59
59
|
with patch("commands.export.load_config", return_value={}), \
|
|
60
60
|
patch("commands.export.discover_tracks", return_value=tracks), \
|
|
61
61
|
patch("commands.export.fetch_export_issues", return_value=export_map) as mock_fei, \
|
|
62
|
-
patch("commands.export.
|
|
62
|
+
patch("commands.export.fetch_visibility_concurrent",
|
|
63
|
+
side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
|
|
63
64
|
patch("commands.export.datetime") as mock_dt:
|
|
64
65
|
mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
|
|
65
66
|
buf = io.StringIO()
|
|
@@ -320,14 +321,13 @@ class ExportCommandUntrackedTest(unittest.TestCase):
|
|
|
320
321
|
|
|
321
322
|
vis = vis or {_SHARED_REPO: "PUBLIC"}
|
|
322
323
|
|
|
323
|
-
def _fake_open_issues(repo, limit=1000):
|
|
324
|
-
return open_rows_by_repo.get(repo, [])
|
|
325
|
-
|
|
326
324
|
with patch("commands.export.load_config", return_value={}), \
|
|
327
325
|
patch("commands.export.discover_tracks", return_value=tracks), \
|
|
328
326
|
patch("commands.export.fetch_export_issues", return_value=export_map), \
|
|
329
|
-
patch("commands.export.
|
|
330
|
-
|
|
327
|
+
patch("commands.export.fetch_open_issues_concurrent",
|
|
328
|
+
side_effect=lambda repos: {r: open_rows_by_repo.get(r, []) for r in repos}), \
|
|
329
|
+
patch("commands.export.fetch_visibility_concurrent",
|
|
330
|
+
side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
|
|
331
331
|
patch("commands.export.datetime") as mock_dt:
|
|
332
332
|
mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
|
|
333
333
|
buf = io.StringIO()
|
|
@@ -407,6 +407,25 @@ class ExportCommandUntrackedTest(unittest.TestCase):
|
|
|
407
407
|
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
408
408
|
json.dumps(out) # must not raise
|
|
409
409
|
|
|
410
|
+
def test_untracked_order_is_deterministic_across_many_repos(self):
|
|
411
|
+
"""`tracked_repos` must be a first-seen-order list, not a set — a set's
|
|
412
|
+
iteration order varies run-to-run (hash randomization), which would
|
|
413
|
+
make `out["untracked"]`'s ordering nondeterministic since it iterates
|
|
414
|
+
that structure directly to build output (no sort downstream)."""
|
|
415
|
+
repo_names = [f"org/repo{i}" for i in range(8)]
|
|
416
|
+
tracks = [_track(f"t{i}", repo_names[i], [i]) for i in range(8)]
|
|
417
|
+
export_map = {(repo_names[i], i): {"number": i, "title": f"issue{i}",
|
|
418
|
+
"state": "OPEN", "assignees": [], "milestone": None}
|
|
419
|
+
for i in range(8)}
|
|
420
|
+
open_rows = {r: [{"number": 900 + idx, "title": "extra", "state": "OPEN",
|
|
421
|
+
"assignees": [], "milestone": None}]
|
|
422
|
+
for idx, r in enumerate(repo_names)}
|
|
423
|
+
vis = {r: "PUBLIC" for r in repo_names}
|
|
424
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows, vis=vis)
|
|
425
|
+
self.assertEqual(rc, 0)
|
|
426
|
+
seen_order = [entry["repo"] for entry in out["untracked"]]
|
|
427
|
+
self.assertEqual(seen_order, repo_names)
|
|
428
|
+
|
|
410
429
|
|
|
411
430
|
class ExportCommandGateTest(unittest.TestCase):
|
|
412
431
|
def test_requires_json_flag(self):
|
|
@@ -543,6 +562,139 @@ class ExportPlanBadgeTest(unittest.TestCase):
|
|
|
543
562
|
self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
|
|
544
563
|
|
|
545
564
|
|
|
565
|
+
class ExportPlanBadgeBatchingTest(unittest.TestCase):
|
|
566
|
+
"""#422: linked plans sharing a local clone batch their git history into
|
|
567
|
+
ONE paths_last_commit_dates call instead of one per doc/declared path."""
|
|
568
|
+
|
|
569
|
+
def _write_plan(self, root, rel, body):
|
|
570
|
+
path = root / rel
|
|
571
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
572
|
+
path.write_text(body)
|
|
573
|
+
|
|
574
|
+
def _track(self, name, folder, plan_rel):
|
|
575
|
+
return SimpleNamespace(
|
|
576
|
+
name=name, repo="o/r", tier="private", folder=folder,
|
|
577
|
+
path=Path(f"/tmp/notes/{name}.md"), has_frontmatter=True,
|
|
578
|
+
meta={"status": "active", "plan": plan_rel, "github": {"repo": "o/r", "issues": []}})
|
|
579
|
+
|
|
580
|
+
def _run(self, tracks, local_by_folder, paths_side_effect=None):
|
|
581
|
+
import io
|
|
582
|
+
import tempfile as _tf
|
|
583
|
+
from contextlib import redirect_stdout
|
|
584
|
+
from datetime import date as _date
|
|
585
|
+
|
|
586
|
+
def _resolve_local(folder, cfg):
|
|
587
|
+
return local_by_folder.get(folder)
|
|
588
|
+
|
|
589
|
+
with patch("commands.export.load_config", return_value={}), \
|
|
590
|
+
patch("commands.export.discover_tracks", return_value=tracks), \
|
|
591
|
+
patch("commands.export.fetch_export_issues", return_value={}), \
|
|
592
|
+
patch("commands.export.fetch_visibility_concurrent", return_value={}), \
|
|
593
|
+
patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
|
|
594
|
+
patch("commands.export.resolve_local_path_for_folder", side_effect=_resolve_local), \
|
|
595
|
+
patch("commands.export.paths_last_commit_dates",
|
|
596
|
+
side_effect=paths_side_effect or (lambda paths, root: {})) as mock_pld, \
|
|
597
|
+
patch("commands.export.datetime") as mock_dt:
|
|
598
|
+
mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
|
|
599
|
+
buf = io.StringIO()
|
|
600
|
+
with redirect_stdout(buf):
|
|
601
|
+
rc = export_cmd.run(["--json"])
|
|
602
|
+
return rc, json.loads(buf.getvalue()), mock_pld
|
|
603
|
+
|
|
604
|
+
def test_two_tracks_same_clone_share_one_batched_call(self):
|
|
605
|
+
import tempfile
|
|
606
|
+
with tempfile.TemporaryDirectory() as d:
|
|
607
|
+
root = Path(d)
|
|
608
|
+
self._write_plan(root, "docs/plans/a.md",
|
|
609
|
+
"# A\n**Files:**\n- Create: `src/a.ts`\n")
|
|
610
|
+
self._write_plan(root, "docs/plans/b.md",
|
|
611
|
+
"# B\n**Files:**\n- Create: `src/b.ts`\n")
|
|
612
|
+
(root / "src").mkdir()
|
|
613
|
+
(root / "src/a.ts").write_text("a")
|
|
614
|
+
(root / "src/b.ts").write_text("b")
|
|
615
|
+
track_a = self._track("alpha", "demo", "docs/plans/a.md")
|
|
616
|
+
track_b = self._track("beta", "demo", "docs/plans/b.md")
|
|
617
|
+
|
|
618
|
+
rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
|
|
619
|
+
|
|
620
|
+
self.assertEqual(rc, 0)
|
|
621
|
+
self.assertEqual(mock_pld.call_count, 1)
|
|
622
|
+
called_paths, called_root = mock_pld.call_args[0]
|
|
623
|
+
self.assertEqual(called_root, root)
|
|
624
|
+
self.assertEqual(
|
|
625
|
+
set(called_paths),
|
|
626
|
+
{"docs/plans/a.md", "docs/plans/b.md", "src/a.ts", "src/b.ts"},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
def test_two_tracks_different_clones_batch_independently(self):
|
|
630
|
+
import tempfile
|
|
631
|
+
with tempfile.TemporaryDirectory() as d1, tempfile.TemporaryDirectory() as d2:
|
|
632
|
+
root1, root2 = Path(d1), Path(d2)
|
|
633
|
+
self._write_plan(root1, "docs/plans/a.md", "# A\n")
|
|
634
|
+
self._write_plan(root2, "docs/plans/b.md", "# B\n")
|
|
635
|
+
track_a = self._track("alpha", "repo1", "docs/plans/a.md")
|
|
636
|
+
track_b = self._track("beta", "repo2", "docs/plans/b.md")
|
|
637
|
+
|
|
638
|
+
rc, out, mock_pld = self._run(
|
|
639
|
+
[track_a, track_b], {"repo1": root1, "repo2": root2})
|
|
640
|
+
|
|
641
|
+
self.assertEqual(rc, 0)
|
|
642
|
+
self.assertEqual(mock_pld.call_count, 2)
|
|
643
|
+
roots_called = {c.args[1] for c in mock_pld.call_args_list}
|
|
644
|
+
self.assertEqual(roots_called, {root1, root2})
|
|
645
|
+
|
|
646
|
+
def test_unresolved_tracks_never_trigger_a_batch_call(self):
|
|
647
|
+
track = self._track("alpha", "missing", "docs/plans/a.md")
|
|
648
|
+
rc, out, mock_pld = self._run([track], {}) # no local clone resolves
|
|
649
|
+
self.assertEqual(rc, 0)
|
|
650
|
+
mock_pld.assert_not_called()
|
|
651
|
+
|
|
652
|
+
def test_overlapping_declared_path_across_two_plans_deduped_in_one_call(self):
|
|
653
|
+
"""Two linked plans in the same clone that both declare the SAME
|
|
654
|
+
path — the shared path appears once in the batched request."""
|
|
655
|
+
import tempfile
|
|
656
|
+
with tempfile.TemporaryDirectory() as d:
|
|
657
|
+
root = Path(d)
|
|
658
|
+
self._write_plan(root, "docs/plans/a.md",
|
|
659
|
+
"# A\n**Files:**\n- Create: `src/shared.ts`\n")
|
|
660
|
+
self._write_plan(root, "docs/plans/b.md",
|
|
661
|
+
"# B\n**Files:**\n- Modify: `src/shared.ts`\n")
|
|
662
|
+
(root / "src").mkdir()
|
|
663
|
+
(root / "src/shared.ts").write_text("shared")
|
|
664
|
+
track_a = self._track("alpha", "demo", "docs/plans/a.md")
|
|
665
|
+
track_b = self._track("beta", "demo", "docs/plans/b.md")
|
|
666
|
+
|
|
667
|
+
rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
|
|
668
|
+
|
|
669
|
+
self.assertEqual(mock_pld.call_count, 1)
|
|
670
|
+
called_paths = mock_pld.call_args[0][0]
|
|
671
|
+
self.assertEqual(called_paths.count("src/shared.ts"), 1)
|
|
672
|
+
|
|
673
|
+
def test_batched_run_produces_correct_verdict_and_lie_gap(self):
|
|
674
|
+
"""Same body/expected verdict as ExportPlanBadgeTest's direct-call
|
|
675
|
+
test_resolved_badge_with_lie_gap — batching changes call count, not
|
|
676
|
+
verdict/lie_gap semantics."""
|
|
677
|
+
import tempfile
|
|
678
|
+
with tempfile.TemporaryDirectory() as d:
|
|
679
|
+
root = Path(d)
|
|
680
|
+
body = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n"
|
|
681
|
+
"- [ ] Step 1\n- [ ] Step 2\n")
|
|
682
|
+
self._write_plan(root, "docs/plans/p.md", body)
|
|
683
|
+
(root / "src").mkdir()
|
|
684
|
+
(root / "src/new.ts").write_text("export const x = 1")
|
|
685
|
+
track = self._track("alpha", "demo", "docs/plans/p.md")
|
|
686
|
+
|
|
687
|
+
rc, out, mock_pld = self._run([track], {"demo": root})
|
|
688
|
+
|
|
689
|
+
self.assertEqual(rc, 0)
|
|
690
|
+
badge = out["tracks"][0]["plan"]
|
|
691
|
+
self.assertTrue(badge["resolved"])
|
|
692
|
+
self.assertEqual(badge["verdict"], "shipped")
|
|
693
|
+
self.assertEqual(badge["files_present"], 1)
|
|
694
|
+
self.assertEqual(badge["files_declared"], 1)
|
|
695
|
+
self.assertTrue(badge["lie_gap"])
|
|
696
|
+
|
|
697
|
+
|
|
546
698
|
class ExportHotByTrackTest(unittest.TestCase):
|
|
547
699
|
def test_export_marks_in_progress_from_hot_branch(self):
|
|
548
700
|
import io
|
|
@@ -556,8 +708,9 @@ class ExportHotByTrackTest(unittest.TestCase):
|
|
|
556
708
|
patch("commands.export.discover_tracks", return_value=[track]), \
|
|
557
709
|
patch("commands.export.fetch_export_issues",
|
|
558
710
|
return_value={("o/r", 1): issue}), \
|
|
559
|
-
patch("commands.export.
|
|
560
|
-
patch("commands.export.
|
|
711
|
+
patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
|
|
712
|
+
patch("commands.export.fetch_visibility_concurrent",
|
|
713
|
+
side_effect=lambda repos: {r: "PRIVATE" for r in repos}), \
|
|
561
714
|
patch("commands.export.resolve_local_path_for_folder",
|
|
562
715
|
return_value=Path("/repo")), \
|
|
563
716
|
patch("commands.export.hot_issue_numbers", return_value={1}), \
|
|
@@ -12,7 +12,9 @@ from lib.github_state import (
|
|
|
12
12
|
fetch_issues, fetch_issue, fetch_issues_concurrent,
|
|
13
13
|
fetch_repo_issues_graphql, fetch_export_issues, _normalize_gql_node,
|
|
14
14
|
extract_priority, fetch_recent_issues, short_milestone,
|
|
15
|
-
repo_visibility, _VIS_CACHE, fetch_open_issues,
|
|
15
|
+
repo_visibility, _VIS_CACHE, fetch_open_issues, repo_full_name,
|
|
16
|
+
fetch_visibility_concurrent, fetch_open_issues_concurrent,
|
|
17
|
+
gh_auth_status,
|
|
16
18
|
_GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
|
|
17
19
|
_gql_query, _GQL_ISSUE_DEPS,
|
|
18
20
|
)
|
|
@@ -104,6 +106,56 @@ class RepoVisibilityTest(unittest.TestCase):
|
|
|
104
106
|
self.assertEqual(m.call_count, 1)
|
|
105
107
|
|
|
106
108
|
|
|
109
|
+
class FetchVisibilityConcurrentTest(unittest.TestCase):
|
|
110
|
+
"""Unit tests for the per-repo concurrent batch fetch_visibility_concurrent() (#424)."""
|
|
111
|
+
|
|
112
|
+
@patch("lib.github_state.repo_visibility")
|
|
113
|
+
def test_returns_keyed_dict_per_repo(self, mock_rv):
|
|
114
|
+
mock_rv.side_effect = lambda r: {"org/a": "PUBLIC", "org/b": "PRIVATE"}.get(r)
|
|
115
|
+
result = fetch_visibility_concurrent(["org/a", "org/b"])
|
|
116
|
+
self.assertEqual(result, {"org/a": "PUBLIC", "org/b": "PRIVATE"})
|
|
117
|
+
|
|
118
|
+
@patch("lib.github_state.repo_visibility")
|
|
119
|
+
def test_dedupes_repeated_repos(self, mock_rv):
|
|
120
|
+
mock_rv.return_value = "PUBLIC"
|
|
121
|
+
fetch_visibility_concurrent(["org/a", "org/a", "org/a"])
|
|
122
|
+
self.assertEqual(mock_rv.call_count, 1)
|
|
123
|
+
|
|
124
|
+
@patch("lib.github_state.repo_visibility")
|
|
125
|
+
def test_falsy_repos_filtered(self, mock_rv):
|
|
126
|
+
mock_rv.return_value = "PUBLIC"
|
|
127
|
+
result = fetch_visibility_concurrent(["org/a", None, "", "org/a"])
|
|
128
|
+
self.assertEqual(list(result.keys()), ["org/a"])
|
|
129
|
+
|
|
130
|
+
def test_empty_input_returns_empty_dict_no_gh_call(self):
|
|
131
|
+
with patch("lib.github_state.repo_visibility") as mock_rv:
|
|
132
|
+
self.assertEqual(fetch_visibility_concurrent([]), {})
|
|
133
|
+
mock_rv.assert_not_called()
|
|
134
|
+
|
|
135
|
+
@patch("lib.github_state.repo_visibility")
|
|
136
|
+
def test_concurrency_bound_is_respected(self, mock_rv):
|
|
137
|
+
"""Peak simultaneous in-flight calls never exceeds max_workers, and
|
|
138
|
+
does exceed 1 — proving work is genuinely bounded-concurrent, not
|
|
139
|
+
serial (#424 acceptance criteria)."""
|
|
140
|
+
import threading, time
|
|
141
|
+
lock = threading.Lock()
|
|
142
|
+
state = {"current": 0, "peak": 0}
|
|
143
|
+
|
|
144
|
+
def _tracked(repo):
|
|
145
|
+
with lock:
|
|
146
|
+
state["current"] += 1
|
|
147
|
+
state["peak"] = max(state["peak"], state["current"])
|
|
148
|
+
time.sleep(0.05)
|
|
149
|
+
with lock:
|
|
150
|
+
state["current"] -= 1
|
|
151
|
+
return "PUBLIC"
|
|
152
|
+
mock_rv.side_effect = _tracked
|
|
153
|
+
repos = [f"org/repo{i}" for i in range(10)]
|
|
154
|
+
fetch_visibility_concurrent(repos, max_workers=3)
|
|
155
|
+
self.assertLessEqual(state["peak"], 3)
|
|
156
|
+
self.assertGreater(state["peak"], 1)
|
|
157
|
+
|
|
158
|
+
|
|
107
159
|
_ISSUE_JSON = '{"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": null, "url": "u", "closedAt": null, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}'
|
|
108
160
|
_ISSUE_DICT = {"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": None, "url": "u", "closedAt": None, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}
|
|
109
161
|
|
|
@@ -544,6 +596,57 @@ class FetchOpenIssuesTest(unittest.TestCase):
|
|
|
544
596
|
self.assertIsInstance(result, list)
|
|
545
597
|
|
|
546
598
|
|
|
599
|
+
class FetchOpenIssuesConcurrentTest(unittest.TestCase):
|
|
600
|
+
"""Unit tests for the per-repo concurrent batch fetch_open_issues_concurrent() (#424)."""
|
|
601
|
+
|
|
602
|
+
@patch("lib.github_state.fetch_open_issues")
|
|
603
|
+
def test_returns_keyed_dict_per_repo(self, mock_foi):
|
|
604
|
+
mock_foi.side_effect = lambda repo: [{"number": 1, "repo": repo}]
|
|
605
|
+
result = fetch_open_issues_concurrent(["org/a", "org/b"])
|
|
606
|
+
self.assertEqual(result["org/a"], [{"number": 1, "repo": "org/a"}])
|
|
607
|
+
self.assertEqual(result["org/b"], [{"number": 1, "repo": "org/b"}])
|
|
608
|
+
|
|
609
|
+
@patch("lib.github_state.fetch_open_issues")
|
|
610
|
+
def test_dedupes_repeated_repos(self, mock_foi):
|
|
611
|
+
mock_foi.return_value = []
|
|
612
|
+
fetch_open_issues_concurrent(["org/a", "org/a", "org/a"])
|
|
613
|
+
self.assertEqual(mock_foi.call_count, 1)
|
|
614
|
+
|
|
615
|
+
@patch("lib.github_state.fetch_open_issues")
|
|
616
|
+
def test_falsy_repos_filtered(self, mock_foi):
|
|
617
|
+
mock_foi.return_value = []
|
|
618
|
+
result = fetch_open_issues_concurrent(["org/a", None, "", "org/a"])
|
|
619
|
+
self.assertEqual(list(result.keys()), ["org/a"])
|
|
620
|
+
|
|
621
|
+
def test_empty_input_returns_empty_dict_no_gh_call(self):
|
|
622
|
+
with patch("lib.github_state.fetch_open_issues") as mock_foi:
|
|
623
|
+
self.assertEqual(fetch_open_issues_concurrent([]), {})
|
|
624
|
+
mock_foi.assert_not_called()
|
|
625
|
+
|
|
626
|
+
@patch("lib.github_state.fetch_open_issues")
|
|
627
|
+
def test_concurrency_bound_is_respected(self, mock_foi):
|
|
628
|
+
"""Peak simultaneous in-flight calls never exceeds max_workers, and
|
|
629
|
+
does exceed 1 — proving work is genuinely bounded-concurrent, not
|
|
630
|
+
serial (#424 acceptance criteria)."""
|
|
631
|
+
import threading, time
|
|
632
|
+
lock = threading.Lock()
|
|
633
|
+
state = {"current": 0, "peak": 0}
|
|
634
|
+
|
|
635
|
+
def _tracked(repo):
|
|
636
|
+
with lock:
|
|
637
|
+
state["current"] += 1
|
|
638
|
+
state["peak"] = max(state["peak"], state["current"])
|
|
639
|
+
time.sleep(0.05)
|
|
640
|
+
with lock:
|
|
641
|
+
state["current"] -= 1
|
|
642
|
+
return []
|
|
643
|
+
mock_foi.side_effect = _tracked
|
|
644
|
+
repos = [f"org/repo{i}" for i in range(10)]
|
|
645
|
+
fetch_open_issues_concurrent(repos, max_workers=3)
|
|
646
|
+
self.assertLessEqual(state["peak"], 3)
|
|
647
|
+
self.assertGreater(state["peak"], 1)
|
|
648
|
+
|
|
649
|
+
|
|
547
650
|
class GqlFieldSetsTest(unittest.TestCase):
|
|
548
651
|
def test_lean_set_requests_labels(self):
|
|
549
652
|
# The export path uses the lean set; without labels the in-progress
|
|
@@ -608,5 +711,44 @@ class NormalizeDepsTest(unittest.TestCase):
|
|
|
608
711
|
self.assertFalse(out["deps_truncated"])
|
|
609
712
|
|
|
610
713
|
|
|
714
|
+
class TestRepoFullName(unittest.TestCase):
|
|
715
|
+
def test_returns_full_name_on_success(self):
|
|
716
|
+
fake = MagicMock(returncode=0, stdout="org/repo-renamed\n", stderr="")
|
|
717
|
+
with patch("lib.github_state.subprocess.run", return_value=fake) as m:
|
|
718
|
+
result = repo_full_name("org/repo-old")
|
|
719
|
+
self.assertEqual(result, "org/repo-renamed")
|
|
720
|
+
args = m.call_args[0][0]
|
|
721
|
+
self.assertEqual(args, ["gh", "api", "repos/org/repo-old", "--jq", ".full_name"])
|
|
722
|
+
|
|
723
|
+
def test_none_on_nonzero_exit(self):
|
|
724
|
+
fake = MagicMock(returncode=1, stdout="", stderr="not found")
|
|
725
|
+
with patch("lib.github_state.subprocess.run", return_value=fake):
|
|
726
|
+
self.assertIsNone(repo_full_name("org/gone"))
|
|
727
|
+
|
|
728
|
+
def test_none_on_invalid_slug(self):
|
|
729
|
+
self.assertIsNone(repo_full_name("not-a-slug"))
|
|
730
|
+
|
|
731
|
+
def test_none_on_subprocess_exception(self):
|
|
732
|
+
with patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
|
|
733
|
+
self.assertIsNone(repo_full_name("org/repo"))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _gh_authenticated():
|
|
737
|
+
return gh_auth_status()["authenticated"]
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class TestRepoFullNameLiveRedirect(unittest.TestCase):
|
|
741
|
+
def test_musical_family_trees_redirect_is_still_live(self):
|
|
742
|
+
# The actual repo this whole design's incident was about. If this ever
|
|
743
|
+
# stops returning the renamed slug, the redirect has lapsed and the
|
|
744
|
+
# design's core "gh api repos/<slug> follows GitHub's own rename
|
|
745
|
+
# redirect" assumption needs re-verifying before trusting doctor's
|
|
746
|
+
# output on real data.
|
|
747
|
+
if not _gh_authenticated():
|
|
748
|
+
self.skipTest("gh not authenticated — skipping live network check")
|
|
749
|
+
result = repo_full_name("evemcgivern/musical-family-trees")
|
|
750
|
+
self.assertEqual(result, "evemcgivern/soundstellation")
|
|
751
|
+
|
|
752
|
+
|
|
611
753
|
if __name__ == "__main__":
|
|
612
754
|
unittest.main()
|
|
@@ -188,6 +188,36 @@ class RemoteAndOwnershipTest(unittest.TestCase):
|
|
|
188
188
|
self.assertTrue(notes_vcs.is_owned(Path(d)))
|
|
189
189
|
|
|
190
190
|
|
|
191
|
+
class DirtyPathsCheckedTest(unittest.TestCase):
|
|
192
|
+
def test_ok_true_on_clean_tree(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as d:
|
|
194
|
+
with patch.object(notes_vcs, "_git", _FakeGit(porcelain="")):
|
|
195
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
196
|
+
self.assertTrue(ok)
|
|
197
|
+
self.assertEqual(paths, set())
|
|
198
|
+
|
|
199
|
+
def test_ok_true_with_dirty_paths(self):
|
|
200
|
+
with tempfile.TemporaryDirectory() as d:
|
|
201
|
+
with patch.object(notes_vcs, "_git",
|
|
202
|
+
_FakeGit(porcelain=" M foo.md\n?? bar.md\n")):
|
|
203
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
204
|
+
self.assertTrue(ok)
|
|
205
|
+
self.assertEqual(paths, {"foo.md", "bar.md"})
|
|
206
|
+
|
|
207
|
+
def test_ok_false_on_git_call_failure(self):
|
|
208
|
+
with tempfile.TemporaryDirectory() as d:
|
|
209
|
+
with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
|
|
210
|
+
ok, paths = notes_vcs.dirty_paths_checked(Path(d))
|
|
211
|
+
self.assertFalse(ok)
|
|
212
|
+
self.assertEqual(paths, set())
|
|
213
|
+
|
|
214
|
+
def test_dirty_paths_thin_wrapper_matches_checked(self):
|
|
215
|
+
with tempfile.TemporaryDirectory() as d:
|
|
216
|
+
with patch.object(notes_vcs, "_git",
|
|
217
|
+
_FakeGit(porcelain=" M foo.md\n")):
|
|
218
|
+
self.assertEqual(notes_vcs.dirty_paths(Path(d)), {"foo.md"})
|
|
219
|
+
|
|
220
|
+
|
|
191
221
|
class DirtyPathsTest(unittest.TestCase):
|
|
192
222
|
def test_parses_porcelain_into_path_set(self):
|
|
193
223
|
body = " M alpha.md\n?? beta.md\n D gone.md\n"
|
|
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
|
|
9
9
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
10
10
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
11
11
|
|
|
12
|
-
from lib.tracks import discover_tracks, discover_archived_tracks
|
|
12
|
+
from lib.tracks import discover_tracks, discover_archived_tracks, iter_private_track_paths
|
|
13
13
|
|
|
14
14
|
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
15
15
|
|
|
@@ -463,5 +463,42 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
|
|
|
463
463
|
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
464
464
|
|
|
465
465
|
|
|
466
|
+
class TestIterPrivateTrackPaths(unittest.TestCase):
|
|
467
|
+
def _make_tree(self, root: Path):
|
|
468
|
+
(root / "proj-a").mkdir(parents=True)
|
|
469
|
+
(root / "proj-a" / "track-one.md").write_text("---\n---\nbody")
|
|
470
|
+
(root / "proj-a" / "_draft.md").write_text("---\n---\nbody")
|
|
471
|
+
(root / "proj-a" / "archive").mkdir()
|
|
472
|
+
(root / "proj-a" / "archive" / "old-track.md").write_text("---\n---\nbody")
|
|
473
|
+
(root / "proj-a" / ".hidden.md").write_text("---\n---\nbody")
|
|
474
|
+
|
|
475
|
+
def test_excludes_archive_by_default(self):
|
|
476
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
477
|
+
root = Path(tmp)
|
|
478
|
+
self._make_tree(root)
|
|
479
|
+
paths = iter_private_track_paths(root, include_archive=False)
|
|
480
|
+
names = {p.name for p in paths}
|
|
481
|
+
self.assertEqual(names, {"track-one.md"})
|
|
482
|
+
|
|
483
|
+
def test_includes_archive_when_requested(self):
|
|
484
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
485
|
+
root = Path(tmp)
|
|
486
|
+
self._make_tree(root)
|
|
487
|
+
paths = iter_private_track_paths(root, include_archive=True)
|
|
488
|
+
names = {p.name for p in paths}
|
|
489
|
+
self.assertEqual(names, {"track-one.md", "old-track.md"})
|
|
490
|
+
|
|
491
|
+
def test_skips_dotfile_underscore_dash_prefixed_names_in_both_modes(self):
|
|
492
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
493
|
+
root = Path(tmp)
|
|
494
|
+
self._make_tree(root)
|
|
495
|
+
(root / "proj-a" / "-dash.md").write_text("---\n---\nbody")
|
|
496
|
+
for include_archive in (False, True):
|
|
497
|
+
names = {p.name for p in iter_private_track_paths(root, include_archive)}
|
|
498
|
+
self.assertNotIn("_draft.md", names)
|
|
499
|
+
self.assertNotIn(".hidden.md", names)
|
|
500
|
+
self.assertNotIn("-dash.md", names)
|
|
501
|
+
|
|
502
|
+
|
|
466
503
|
if __name__ == "__main__":
|
|
467
504
|
unittest.main()
|
|
@@ -61,6 +61,7 @@ SUBCOMMANDS = {
|
|
|
61
61
|
"in-progress": "commands.in_progress",
|
|
62
62
|
"export": "commands.export",
|
|
63
63
|
"which-repo": "commands.which_repo",
|
|
64
|
+
"doctor": "commands.doctor",
|
|
64
65
|
"auth-status": "commands.auth_status",
|
|
65
66
|
"list-open-issues": "commands.list_open_issues",
|
|
66
67
|
"set": "commands.set_field",
|
|
@@ -259,6 +260,17 @@ DESCRIPTIONS = [
|
|
|
259
260
|
"Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.",
|
|
260
261
|
"Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.",
|
|
261
262
|
"/work-plan which-repo --json"),
|
|
263
|
+
("doctor", "[--json] [--fix]",
|
|
264
|
+
"Detect drift between config.yml, local git clones, GitHub, and notes_root track "
|
|
265
|
+
"frontmatter — a renamed local folder or GitHub repo that config.yml no longer "
|
|
266
|
+
"matches, a non-git local path, duplicate entries, an invalid/missing notes_root, "
|
|
267
|
+
"an orphaned notes folder, or a stale per-track github.repo. --fix corrects only "
|
|
268
|
+
"the two mechanically-safe cases (a GitHub-confirmed rename, a stale track slug) "
|
|
269
|
+
"and always re-scans afterward before deciding success.",
|
|
270
|
+
"Run right after renaming a project's local folder or its GitHub repo — this is "
|
|
271
|
+
"exactly the class of bug that silently breaks the VS Code viewer's Auto Focus "
|
|
272
|
+
"Repo setting with zero visible signal.",
|
|
273
|
+
"/work-plan doctor --fix"),
|
|
262
274
|
]
|
|
263
275
|
|
|
264
276
|
|
|
@@ -352,6 +364,14 @@ _READONLY_SUBCOMMANDS = frozenset({
|
|
|
352
364
|
# plan-branch manages its OWN commits on the plan branch (init seeds +
|
|
353
365
|
# commits the skeleton itself); the auto-commit hooks must not also fire.
|
|
354
366
|
"plan-branch",
|
|
367
|
+
# doctor writes config.yml (unversioned, outside any repo) and private
|
|
368
|
+
# notes_root/ frontmatter under --fix, but never a repo's shared
|
|
369
|
+
# .work-plan/ tier — so it's read-only from the dispatcher's perspective
|
|
370
|
+
# too, sidestepping _shared_precommit_state's plan_worktree.ensure_worktree
|
|
371
|
+
# side effect. doctor --fix manages its own notes-vcs commit directly
|
|
372
|
+
# (see commands/doctor.py's dirty-file policy), same self-managed-commit
|
|
373
|
+
# precedent as plan-branch.
|
|
374
|
+
"doctor",
|
|
355
375
|
})
|
|
356
376
|
|
|
357
377
|
|