@stylusnexus/work-plan 2026.6.15 → 2026.6.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.
@@ -0,0 +1,164 @@
1
+ """cwd → configured-repo resolution (#358/#357 Phase 1).
2
+
3
+ All git calls are mocked, so these run offline. The resolver shells `git` via
4
+ `lib.git_state._git`, imported into `lib.cwd_repo`'s namespace — so we patch
5
+ `lib.cwd_repo._git`.
6
+ """
7
+ import sys
8
+ import types
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib import cwd_repo
17
+ from lib.cwd_repo import resolve_repo_for_dir, _normalize_remote_url
18
+
19
+
20
+ def _proc(stdout="", returncode=0):
21
+ """A stand-in for subprocess.CompletedProcess as `_git` returns it."""
22
+ return types.SimpleNamespace(stdout=stdout, returncode=returncode)
23
+
24
+
25
+ def _fake_git(toplevel=None, origin=None):
26
+ """Build a `_git` replacement keyed on the git subcommand.
27
+
28
+ `toplevel` / `origin` are raw stdout strings (or None → non-zero exit, i.e.
29
+ git failed / not a repo / no remote).
30
+ """
31
+ def _g(repo_path, *args, **kwargs):
32
+ if args[:2] == ("rev-parse", "--show-toplevel"):
33
+ return _proc(toplevel, 0) if toplevel is not None else _proc("", 128)
34
+ if args[:2] == ("remote", "get-url"):
35
+ return _proc(origin, 0) if origin is not None else _proc("", 2)
36
+ return _proc("", 0)
37
+ return _g
38
+
39
+
40
+ # Absolute, non-symlinked paths so .resolve() is a no-op-equal on both sides.
41
+ CFG = {
42
+ "repos": {
43
+ "work-plan-toolkit": {
44
+ "local": "/code/work-plan-toolkit",
45
+ "github": "stylusnexus/work-plan-toolkit",
46
+ },
47
+ "defect-scan": {
48
+ "local": "/code/defect-scan",
49
+ "github": "stylusnexus/defect-scan",
50
+ },
51
+ # A repo with no local clone — only a remote can match it.
52
+ "remote-only": {
53
+ "local": None,
54
+ "github": "stylusnexus/remote-only",
55
+ },
56
+ }
57
+ }
58
+
59
+
60
+ class NormalizeRemoteUrlTest(unittest.TestCase):
61
+ def test_scp_form(self):
62
+ self.assertEqual(
63
+ _normalize_remote_url("git@github.com:Org/Repo.git"), "org/repo")
64
+
65
+ def test_https_with_git_suffix(self):
66
+ self.assertEqual(
67
+ _normalize_remote_url("https://github.com/org/repo.git"), "org/repo")
68
+
69
+ def test_https_without_suffix(self):
70
+ self.assertEqual(
71
+ _normalize_remote_url("https://github.com/org/repo"), "org/repo")
72
+
73
+ def test_ssh_url_form(self):
74
+ self.assertEqual(
75
+ _normalize_remote_url("ssh://git@github.com/org/repo.git"), "org/repo")
76
+
77
+ def test_all_forms_land_on_same_slug(self):
78
+ forms = [
79
+ "git@github.com:org/repo.git",
80
+ "https://github.com/org/repo.git",
81
+ "https://github.com/org/repo",
82
+ "ssh://git@github.com/org/repo.git",
83
+ ]
84
+ slugs = {_normalize_remote_url(f) for f in forms}
85
+ self.assertEqual(slugs, {"org/repo"})
86
+
87
+ def test_garbage_returns_none(self):
88
+ self.assertIsNone(_normalize_remote_url(""))
89
+ self.assertIsNone(_normalize_remote_url("not-a-url"))
90
+
91
+
92
+ class ResolveRepoForDirTest(unittest.TestCase):
93
+ def test_local_match_at_clone_root(self):
94
+ with mock.patch.object(cwd_repo, "_git",
95
+ _fake_git(toplevel="/code/work-plan-toolkit")):
96
+ got = resolve_repo_for_dir(CFG, "/code/work-plan-toolkit")
97
+ self.assertEqual(got, {
98
+ "key": "work-plan-toolkit",
99
+ "github": "stylusnexus/work-plan-toolkit",
100
+ "matched_by": "local",
101
+ })
102
+
103
+ def test_local_match_from_nested_subdir(self):
104
+ # cwd is deep inside the clone; toplevel still resolves to the root.
105
+ with mock.patch.object(cwd_repo, "_git",
106
+ _fake_git(toplevel="/code/work-plan-toolkit")):
107
+ got = resolve_repo_for_dir(
108
+ CFG, "/code/work-plan-toolkit/skills/work-plan/lib")
109
+ self.assertIsNotNone(got)
110
+ self.assertEqual(got["key"], "work-plan-toolkit")
111
+ self.assertEqual(got["matched_by"], "local")
112
+
113
+ def test_remote_match_when_local_is_null(self):
114
+ # Not a configured local path, but origin matches the remote-only repo.
115
+ with mock.patch.object(cwd_repo, "_git",
116
+ _fake_git(toplevel="/somewhere/else",
117
+ origin="git@github.com:stylusnexus/remote-only.git")):
118
+ got = resolve_repo_for_dir(CFG, "/somewhere/else")
119
+ self.assertEqual(got, {
120
+ "key": "remote-only",
121
+ "github": "stylusnexus/remote-only",
122
+ "matched_by": "remote",
123
+ })
124
+
125
+ def test_local_wins_over_remote_when_they_disagree(self):
126
+ # toplevel == defect-scan's clone, but origin points at work-plan-toolkit.
127
+ # The local-path key must win.
128
+ with mock.patch.object(cwd_repo, "_git",
129
+ _fake_git(toplevel="/code/defect-scan",
130
+ origin="git@github.com:stylusnexus/work-plan-toolkit.git")):
131
+ got = resolve_repo_for_dir(CFG, "/code/defect-scan")
132
+ self.assertEqual(got["key"], "defect-scan")
133
+ self.assertEqual(got["matched_by"], "local")
134
+
135
+ def test_no_match_inside_unconfigured_repo(self):
136
+ with mock.patch.object(cwd_repo, "_git",
137
+ _fake_git(toplevel="/code/unknown",
138
+ origin="git@github.com:someone/unknown.git")):
139
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/code/unknown"))
140
+
141
+ def test_no_match_when_not_a_git_repo(self):
142
+ # git rev-parse fails AND no remote — resolver returns None, no raise.
143
+ with mock.patch.object(cwd_repo, "_git",
144
+ _fake_git(toplevel=None, origin=None)):
145
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/tmp/plain-dir"))
146
+
147
+ def test_none_when_two_repos_share_a_local_path(self):
148
+ # Pathological config: two keys point at the same clone. Refuse to guess.
149
+ cfg = {"repos": {
150
+ "a": {"local": "/code/dup", "github": "org/a"},
151
+ "b": {"local": "/code/dup", "github": "org/b"},
152
+ }}
153
+ with mock.patch.object(cwd_repo, "_git",
154
+ _fake_git(toplevel="/code/dup")):
155
+ self.assertIsNone(resolve_repo_for_dir(cfg, "/code/dup"))
156
+
157
+ def test_none_when_no_repos_configured(self):
158
+ with mock.patch.object(cwd_repo, "_git",
159
+ _fake_git(toplevel="/code/x")):
160
+ self.assertIsNone(resolve_repo_for_dir({"repos": {}}, "/code/x"))
161
+
162
+
163
+ if __name__ == "__main__":
164
+ unittest.main()
@@ -0,0 +1,147 @@
1
+ """Tests for the dedupe-tiers command and its tracks.py helpers (#359)."""
2
+ import io
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import patch
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import dedupe_tiers
15
+ from lib import tracks
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _track(*, name, repo="org/repo", folder="repo", issues=None, body="", path=None,
23
+ tier="private"):
24
+ meta = {"track": name, "github": {"repo": repo}}
25
+ if issues is not None:
26
+ meta["github"]["issues"] = issues
27
+ return SimpleNamespace(
28
+ name=name,
29
+ path=Path(path) if path else Path(f"/tmp/notes/{folder}/{name}.md"),
30
+ repo=repo,
31
+ folder=folder,
32
+ meta=meta,
33
+ body=body,
34
+ tier=tier,
35
+ )
36
+
37
+
38
+ def _drive(args, pairs, *, notes_root="/tmp/notes"):
39
+ cfg = {"notes_root": notes_root, "repos": {"repo": {"github": "org/repo"}}}
40
+ buf = io.StringIO()
41
+ with patch("commands.dedupe_tiers.load_config", return_value=cfg), \
42
+ patch("commands.dedupe_tiers.find_tier_duplicates", return_value=pairs), \
43
+ redirect_stdout(buf):
44
+ rc = dedupe_tiers.run(args)
45
+ return rc, buf.getvalue()
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # issue_refs
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class TestIssueRefs(unittest.TestCase):
53
+ def test_unions_frontmatter_and_body(self):
54
+ t = _track(name="a", issues=[10, 20], body="see #20 and #30 here")
55
+ self.assertEqual(tracks.issue_refs(t), {10, 20, 30})
56
+
57
+ def test_empty_when_no_refs(self):
58
+ t = _track(name="a", issues=None, body="no refs at all")
59
+ self.assertEqual(tracks.issue_refs(t), set())
60
+
61
+ def test_ignores_non_int_frontmatter(self):
62
+ t = _track(name="a", issues=[1, "oops", None], body="")
63
+ self.assertEqual(tracks.issue_refs(t), {1})
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # find_tier_duplicates pairing (helpers patched)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class TestFindTierDuplicates(unittest.TestCase):
71
+ def test_pairs_only_colliding_active_tracks(self):
72
+ shared = [_track(name="dup", tier="shared"), _track(name="only-shared", tier="shared")]
73
+ private = [_track(name="dup"), _track(name="only-private")]
74
+ cfg = {"notes_root": "/tmp/does-not-exist-xyz", "repos": {}}
75
+ with patch.object(tracks, "_discover_shared_tracks") as ds, \
76
+ patch.object(tracks, "_discover_private_tracks", return_value=private):
77
+ # active call returns `shared`; archive-only call returns []
78
+ ds.side_effect = lambda cfg, include_archive=False, archive_only=False: (
79
+ [] if archive_only else shared)
80
+ pairs = tracks.find_tier_duplicates(cfg)
81
+ names = [(s.name, p.name) for (s, p) in pairs]
82
+ self.assertEqual(names, [("dup", "dup")])
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # command: report / apply / safety
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class TestDedupeCommand(unittest.TestCase):
90
+ def test_no_pairs_reports_nothing(self):
91
+ rc, out = _drive([], [])
92
+ self.assertEqual(rc, 0)
93
+ self.assertIn("Nothing to dedupe", out)
94
+
95
+ def test_dry_run_removes_nothing(self):
96
+ with tempfile.TemporaryDirectory() as d:
97
+ pf = Path(d) / "dup.md"
98
+ pf.write_text("# dup\n")
99
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
100
+ private = _track(name="dup", issues=[1], path=str(pf))
101
+ rc, out = _drive([], [(shared, private)])
102
+ self.assertTrue(pf.exists(), "dry run must not delete")
103
+ self.assertEqual(rc, 0)
104
+ self.assertIn("Dry run", out)
105
+ self.assertIn("--apply", out)
106
+
107
+ def test_apply_removes_subset_orphan(self):
108
+ with tempfile.TemporaryDirectory() as d:
109
+ pf = Path(d) / "dup.md"
110
+ pf.write_text("# dup\n")
111
+ shared = _track(name="dup", issues=[1, 2, 3], tier="shared")
112
+ private = _track(name="dup", issues=[1, 3], path=str(pf))
113
+ rc, out = _drive(["--apply"], [(shared, private)])
114
+ self.assertEqual(rc, 0)
115
+ self.assertFalse(pf.exists(), "subset orphan must be removed on --apply")
116
+ self.assertIn("Removed 1 private orphan", out)
117
+
118
+ def test_apply_keeps_diverged_orphan(self):
119
+ with tempfile.TemporaryDirectory() as d:
120
+ pf = Path(d) / "dup.md"
121
+ pf.write_text("# dup\n")
122
+ # private references #99 which the shared twin lacks → must be kept
123
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
124
+ private = _track(name="dup", issues=[1], body="leftover #99", path=str(pf))
125
+ rc, out = _drive(["--apply"], [(shared, private)])
126
+ self.assertEqual(rc, 0)
127
+ self.assertTrue(pf.exists(), "diverged orphan must NOT be removed")
128
+ self.assertIn("#99", out)
129
+ self.assertIn("manual review", out)
130
+
131
+ def test_repo_filter_scopes_pairs(self):
132
+ a_shared = _track(name="a", repo="org/a", folder="a", issues=[1], tier="shared")
133
+ a_priv = _track(name="a", repo="org/a", folder="a", issues=[1])
134
+ b_shared = _track(name="b", repo="org/b", folder="b", issues=[1], tier="shared")
135
+ b_priv = _track(name="b", repo="org/b", folder="b", issues=[1])
136
+ rc, out = _drive(["--repo=a"], [(a_shared, a_priv), (b_shared, b_priv)])
137
+ self.assertEqual(rc, 0)
138
+ self.assertIn("a (repo org/a)", out)
139
+ self.assertNotIn("org/b", out)
140
+
141
+ def test_repo_flag_without_value_is_usage_error(self):
142
+ rc, _ = _drive(["--repo"], [])
143
+ self.assertEqual(rc, 2)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -33,6 +33,38 @@ class DetectDriftTest(unittest.TestCase):
33
33
  def test_no_table_returns_empty(self):
34
34
  self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
35
35
 
36
+ # --- OPEN-side + ambiguous-body cases: pin the *intentional asymmetry* ----
37
+ # CLOSED is terminal → broad check (anything not-closed drifts). OPEN is not
38
+ # terminal → narrow check (only an explicit closed marker drifts). These
39
+ # guard against someone "restoring symmetry" with a `not looks_open` open-side
40
+ # check, which would false-positive every in-progress row.
41
+
42
+ def _body(self, status: str) -> str:
43
+ return ("| # | Title | Status |\n"
44
+ "|---|---|---|\n"
45
+ f"| #1 | foo | {status} |\n")
46
+
47
+ def test_drift_when_closed_in_md_open_in_github(self):
48
+ # OPEN-side condition (was untested): body says shipped, GitHub reopened it.
49
+ drift = detect_drift(self._body("✅ Shipped"), [{"number": 1, "state": "OPEN"}])
50
+ self.assertEqual(len(drift), 1)
51
+ self.assertEqual(drift[0]["github_state"], "OPEN")
52
+
53
+ def test_no_drift_when_open_in_md_open_in_github(self):
54
+ self.assertEqual(detect_drift(self._body("🔲 Open"), [{"number": 1, "state": "OPEN"}]), [])
55
+
56
+ def test_open_with_ambiguous_status_is_NOT_drift(self):
57
+ # The deliberate narrow OPEN-side: an in-progress row must not be flagged.
58
+ self.assertEqual(
59
+ detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "OPEN"}]), [])
60
+
61
+ def test_closed_with_ambiguous_status_IS_drift(self):
62
+ # The deliberate broad CLOSED-side: a closed issue whose row doesn't read
63
+ # closed (here: ambiguous) is drift.
64
+ drift = detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "CLOSED"}])
65
+ self.assertEqual(len(drift), 1)
66
+ self.assertEqual(drift[0]["github_state"], "CLOSED")
67
+
36
68
 
37
69
  if __name__ == "__main__":
38
70
  unittest.main()
@@ -49,6 +49,69 @@ class BuildExportTest(unittest.TestCase):
49
49
  self.assertIsNone(out["tracks"][0]["path"])
50
50
  json.dumps(out) # null is serializable
51
51
 
52
+ class BuildExportNextUpAutoTest(unittest.TestCase):
53
+ """next_up_auto: true → export derives next_up live via the ranking preset."""
54
+
55
+ def _open(self, n, pri, ms="v1"):
56
+ return {"number": n, "state": "open", "title": f"#{n}",
57
+ "labels": [{"name": f"priority/{pri}"}], "milestone": {"title": ms},
58
+ "updatedAt": "2026-06-10T00:00:00Z", "blocked_by": [], "blocking": []}
59
+
60
+ def _track_auto(self, auto):
61
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
62
+ "blockers": [], "next_up": [7], "depends_on": [],
63
+ "github": {"repo": "o/r", "issues": [1, 2, 3]}}
64
+ if auto:
65
+ meta["next_up_auto"] = True
66
+ return SimpleNamespace(name="t", repo="o/r", tier="private",
67
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
68
+
69
+ def _issues(self):
70
+ # P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
71
+ return [self._open(1, "P2"), self._open(2, "P1"), self._open(3, "P0")]
72
+
73
+ def test_auto_derives_and_flags(self):
74
+ out = build_export([self._track_auto(True)],
75
+ {("o/r", "t"): self._issues()},
76
+ {"o/r": "PRIVATE"}, now="t")
77
+ tr = out["tracks"][0]
78
+ self.assertEqual(tr["next_up"], [3, 2, 1]) # P0 → P1 → P2 (ignores stored [7])
79
+ self.assertTrue(tr["next_up_auto"])
80
+
81
+ def test_no_auto_uses_stored_list(self):
82
+ out = build_export([self._track_auto(False)],
83
+ {("o/r", "t"): self._issues()},
84
+ {"o/r": "PRIVATE"}, now="t")
85
+ tr = out["tracks"][0]
86
+ self.assertEqual(tr["next_up"], [7]) # the curated list, unchanged
87
+ self.assertFalse(tr["next_up_auto"])
88
+
89
+ def test_auto_on_with_zero_open_issues_still_exports_flag_true(self):
90
+ """next_up_auto: true + zero fetched issues → next_up_auto=True in export (the
91
+ SETTING, not whether auto-derivation actually ran). Viewer toggle must show
92
+ On even when there are no issues to rank.
93
+
94
+ When no issues are fetched, the auto-derivation branch does not run (there's
95
+ nothing to rank); the export still emits next_up_auto=True so the viewer
96
+ knows the flag is on. The next_up list itself falls through to the curated
97
+ list (which may be non-empty — that's acceptable and a separate concern)."""
98
+ # Use a track with no stored next_up to isolate the flag assertion cleanly.
99
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
100
+ "blockers": [], "next_up": [], "depends_on": [],
101
+ "next_up_auto": True,
102
+ "github": {"repo": "o/r", "issues": []}}
103
+ from types import SimpleNamespace
104
+ from pathlib import Path
105
+ t = SimpleNamespace(name="t", repo="o/r", tier="private",
106
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
107
+ out = build_export([t],
108
+ {("o/r", "t"): []}, # zero issues fetched
109
+ {"o/r": "PRIVATE"}, now="t")
110
+ tr = out["tracks"][0]
111
+ self.assertTrue(tr["next_up_auto"]) # flag reflects setting, not derivation
112
+ self.assertEqual(tr["next_up"], []) # no issues → empty list
113
+
114
+
52
115
  class BuildExportNextUpFilterTest(unittest.TestCase):
53
116
  """next_up entries whose issue is closed in the fetched payload are filtered out."""
54
117
 
@@ -151,6 +151,49 @@ class ExportRunJsonTest(unittest.TestCase):
151
151
  self.assertEqual(rc, 0)
152
152
  self.assertEqual(out["tracks"][0]["issues"], [])
153
153
 
154
+ # --- tier_duplicates (#361) -------------------------------------------
155
+
156
+ def _dup_track(self, *, issues, body="", path):
157
+ return SimpleNamespace(
158
+ repo=_SHARED_REPO, folder="myrepo", name="dup", path=Path(path),
159
+ meta={"github": {"repo": _SHARED_REPO, "issues": issues}}, body=body,
160
+ )
161
+
162
+ def test_tier_duplicates_empty_when_none(self):
163
+ """With no notes_root in cfg (mock returns {}), the field is an empty
164
+ list — present but quiet, so the viewer can rely on its shape."""
165
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
166
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
167
+ self.assertEqual(rc, 0)
168
+ self.assertEqual(out["tier_duplicates"], [])
169
+
170
+ def test_tier_duplicate_subset_is_safe(self):
171
+ shared = self._dup_track(issues=[1, 2, 3], path="/repo/.work-plan/dup.md")
172
+ private = self._dup_track(issues=[1, 3], path="/notes/myrepo/dup.md")
173
+ with patch("commands.export.find_tier_duplicates",
174
+ return_value=[(shared, private)]):
175
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
176
+ self.assertEqual(rc, 0)
177
+ td = out["tier_duplicates"]
178
+ self.assertEqual(len(td), 1)
179
+ self.assertEqual(td[0]["name"], "dup")
180
+ self.assertEqual(td[0]["repo"], _SHARED_REPO)
181
+ self.assertEqual(td[0]["folder"], "myrepo")
182
+ self.assertTrue(td[0]["safe"])
183
+ self.assertEqual(td[0]["shared_path"], str(Path("/repo/.work-plan/dup.md")))
184
+ self.assertEqual(td[0]["private_path"], str(Path("/notes/myrepo/dup.md")))
185
+
186
+ def test_tier_duplicate_diverged_is_unsafe(self):
187
+ # private references #99, which the shared twin lacks → not safe to remove
188
+ shared = self._dup_track(issues=[1, 2], path="/repo/.work-plan/dup.md")
189
+ private = self._dup_track(issues=[1], body="leftover #99",
190
+ path="/notes/myrepo/dup.md")
191
+ with patch("commands.export.find_tier_duplicates",
192
+ return_value=[(shared, private)]):
193
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
194
+ self.assertEqual(rc, 0)
195
+ self.assertFalse(out["tier_duplicates"][0]["safe"])
196
+
154
197
  def test_visibility_included_in_output(self):
155
198
  tracks = [_track("alpha", _SHARED_REPO, [1])]
156
199
  rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"})
@@ -290,6 +333,19 @@ class ExportCommandUntrackedTest(unittest.TestCase):
290
333
  self.assertEqual(rc, 0)
291
334
  self.assertEqual(out["untracked"], [])
292
335
 
336
+ def test_empty_track_still_surfaces_untracked(self):
337
+ """A repo whose only track has issues:[] must still surface its open
338
+ issues as untracked (#342). repo_to_numbers omits such a track, so the
339
+ untracked loop must key off repos-with-tracks, not tracked issues."""
340
+ tracks = [_track("general", _SHARED_REPO, [])] # empty track
341
+ export_map = {} # no tracked issues to fetch
342
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_C]} # but the repo has open issues
343
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
344
+ self.assertEqual(rc, 0)
345
+ entry = next(e for e in out["untracked"] if e["repo"] == _SHARED_REPO)
346
+ nums = sorted(i["number"] for i in entry["issues"])
347
+ self.assertEqual(nums, [1, 3]) # both open issues are untracked
348
+
293
349
  def test_schema_stays_1_with_untracked(self):
294
350
  tracks = [_track("alpha", _SHARED_REPO, [1])]
295
351
  open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
@@ -0,0 +1,130 @@
1
+ """Tests for `handoff --suggest-next` — the read-only JSON suggestion mode (#274).
2
+
3
+ The VS Code native auto-next picker needs the algorithmic next_up suggestion
4
+ WITHOUT a write or a TTY prompt (the CLI's prompt helpers no-op under VS Code's
5
+ non-TTY stdin). `--suggest-next` computes the same sibling-filtered suggestion as
6
+ `--auto-next` and prints it as JSON; the extension renders + confirms it and
7
+ writes back via `--set-next`. These tests assert: valid JSON shape, sibling-claim
8
+ skips surface in `skipped` (not written), no frontmatter is mutated, and the
9
+ soft-error payloads (no repo / no issues) stay parseable with exit 0.
10
+ """
11
+ import io
12
+ import json
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ from contextlib import redirect_stdout
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(SKILL_ROOT))
22
+
23
+ from commands import handoff
24
+ from lib.frontmatter import parse_file, write_file
25
+
26
+
27
+ def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
28
+ next_up=None, issues=None) -> Path:
29
+ meta = {
30
+ "track": slug,
31
+ "status": status,
32
+ "launch_priority": "P1",
33
+ "github": {"repo": repo,
34
+ "issues": list([100, 200] if issues is None else issues),
35
+ "branches": []},
36
+ "next_up": list(next_up or []),
37
+ }
38
+ path = dir_path / f"{slug}.md"
39
+ write_file(path, meta, f"\n# {slug}\n\nBody.\n")
40
+ return path
41
+
42
+
43
+ def _open_issue(num: int, *, priority: str = "P1", milestone=None) -> dict:
44
+ return {
45
+ "number": num,
46
+ "title": f"issue-{num}",
47
+ "state": "OPEN",
48
+ "labels": [{"name": f"priority/{priority}"}],
49
+ "updatedAt": "2026-04-30T00:00:00Z",
50
+ "milestone": milestone,
51
+ }
52
+
53
+
54
+ class SuggestNextJsonTest(unittest.TestCase):
55
+ def setUp(self):
56
+ self.tmp = tempfile.TemporaryDirectory()
57
+ self.notes_root = Path(self.tmp.name) / "notes_root"
58
+ self.repo_dir = self.notes_root / "demo"
59
+ self.repo_dir.mkdir(parents=True)
60
+ self.cfg = {
61
+ "notes_root": str(self.notes_root),
62
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
63
+ }
64
+ self._patches = [
65
+ mock.patch("commands.handoff.load_config", return_value=self.cfg),
66
+ mock.patch("commands.handoff.has_uncommitted", return_value=False),
67
+ ]
68
+ for p in self._patches:
69
+ p.start()
70
+
71
+ def tearDown(self):
72
+ for p in self._patches:
73
+ p.stop()
74
+ self.tmp.cleanup()
75
+
76
+ def _run(self, track_name, *, issues_response):
77
+ buf = io.StringIO()
78
+ with mock.patch("commands.handoff.fetch_issues", return_value=issues_response), \
79
+ redirect_stdout(buf):
80
+ rc = handoff.run([track_name, "--suggest-next"])
81
+ return rc, buf.getvalue()
82
+
83
+ def test_emits_valid_json_with_decorated_suggestions(self):
84
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
85
+ issues=[100, 200])
86
+ rc, out = self._run("track-a",
87
+ issues_response=[_open_issue(100, milestone={"title": "v1.0.0"}),
88
+ _open_issue(200)])
89
+ self.assertEqual(rc, 0)
90
+ payload = json.loads(out)
91
+ self.assertEqual(payload["track"], "track-a")
92
+ self.assertEqual(payload["repo"], "stylusnexus/Demo")
93
+ nums = [s["number"] for s in payload["suggested"]]
94
+ self.assertEqual(sorted(nums), [100, 200])
95
+ first = next(s for s in payload["suggested"] if s["number"] == 100)
96
+ self.assertEqual(first["title"], "issue-100")
97
+ self.assertEqual(first["priority"], "P1")
98
+ self.assertEqual(first["milestone"], "v1.0.0")
99
+
100
+ def test_read_only_does_not_write_next_up(self):
101
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
102
+ issues=[100, 200], next_up=[7])
103
+ rc, out = self._run("track-a",
104
+ issues_response=[_open_issue(100), _open_issue(200)])
105
+ self.assertEqual(rc, 0)
106
+ meta, _ = parse_file(target)
107
+ self.assertEqual(meta["next_up"], [7]) # untouched by a read-only suggest
108
+
109
+ def test_sibling_claimed_issue_lands_in_skipped_not_suggested(self):
110
+ _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[100, 200])
111
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
112
+ rc, out = self._run("track-a",
113
+ issues_response=[_open_issue(100), _open_issue(200)])
114
+ payload = json.loads(out)
115
+ self.assertEqual([s["number"] for s in payload["suggested"]], [200])
116
+ self.assertEqual(payload["skipped"], [{"number": 100, "claimed_by": "track-b"}])
117
+
118
+ def test_no_issues_attached_is_soft_error_with_empty_suggested(self):
119
+ _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[])
120
+ buf = io.StringIO()
121
+ with redirect_stdout(buf):
122
+ rc = handoff.run(["track-a", "--suggest-next"])
123
+ self.assertEqual(rc, 0)
124
+ payload = json.loads(buf.getvalue())
125
+ self.assertEqual(payload["suggested"], [])
126
+ self.assertIn("error", payload)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
@@ -0,0 +1,22 @@
1
+ """which-repo is dispatchable + documented (#358/#357 Phase 1)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ import work_plan
10
+
11
+
12
+ class RegisterWhichRepoTest(unittest.TestCase):
13
+ def test_in_subcommands(self):
14
+ self.assertEqual(work_plan.SUBCOMMANDS["which-repo"], "commands.which_repo")
15
+
16
+ def test_in_descriptions(self):
17
+ names = {row[0] for row in work_plan.DESCRIPTIONS}
18
+ self.assertIn("which-repo", names)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()