@stylusnexus/work-plan 2026.6.15 → 2026.6.18
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 +14 -12
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +17 -7
- package/scripts/npm-check-deps.js +0 -44
|
@@ -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,114 @@
|
|
|
1
|
+
"""Tests for lib.heuristic_triage — the offline (no-LLM) track scorer (#373).
|
|
2
|
+
|
|
3
|
+
Covers: milestone match, track-label overlap (incl. the track/<slug> default),
|
|
4
|
+
keyword overlap, abstain when nothing clears the bar, margin clear-vs-narrow from
|
|
5
|
+
the top-vs-runner gap, and the v2 entry shape.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from lib.heuristic_triage import score_suggestions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iss(number, title="", milestone=None, labels=()):
|
|
18
|
+
return {
|
|
19
|
+
"number": number,
|
|
20
|
+
"title": title,
|
|
21
|
+
"milestone": {"title": milestone} if milestone else None,
|
|
22
|
+
"labels": [{"name": n} for n in labels],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _trk(slug, name=None, milestone=None, scope="", labels=None):
|
|
27
|
+
return {"slug": slug, "name": name or slug, "milestone": milestone,
|
|
28
|
+
"scope": scope, "labels": labels}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HeuristicScoreTest(unittest.TestCase):
|
|
32
|
+
|
|
33
|
+
def _one(self, entries):
|
|
34
|
+
self.assertEqual(len(entries), 1)
|
|
35
|
+
return entries[0]
|
|
36
|
+
|
|
37
|
+
def test_milestone_match_suggests(self):
|
|
38
|
+
e = self._one(score_suggestions(
|
|
39
|
+
[_iss(1, "rate limit", milestone="v0.4")],
|
|
40
|
+
[_trk("auth-flow", milestone="v0.4")],
|
|
41
|
+
))
|
|
42
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
43
|
+
self.assertEqual(e["track"], "auth-flow")
|
|
44
|
+
self.assertIn("milestone v0.4", e["rationale"])
|
|
45
|
+
|
|
46
|
+
def test_track_label_overlap_suggests(self):
|
|
47
|
+
e = self._one(score_suggestions(
|
|
48
|
+
[_iss(2, "something", labels=["area/auth"])],
|
|
49
|
+
[_trk("auth-flow", labels=["area/auth"])],
|
|
50
|
+
))
|
|
51
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
52
|
+
self.assertIn("label area/auth", e["rationale"])
|
|
53
|
+
|
|
54
|
+
def test_default_track_slug_label(self):
|
|
55
|
+
# No github.labels on the track → default `track/<slug>` is matched.
|
|
56
|
+
e = self._one(score_suggestions(
|
|
57
|
+
[_iss(3, "x", labels=["track/auth-flow"])],
|
|
58
|
+
[_trk("auth-flow", labels=None)],
|
|
59
|
+
))
|
|
60
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
61
|
+
|
|
62
|
+
def test_keyword_only_below_bar_abstains(self):
|
|
63
|
+
# A single keyword hit (0.1) is below the 0.3 suggest bar → abstain.
|
|
64
|
+
e = self._one(score_suggestions(
|
|
65
|
+
[_iss(4, "sessions cleanup")],
|
|
66
|
+
[_trk("tabletop-sessions", name="tabletop sessions")],
|
|
67
|
+
))
|
|
68
|
+
self.assertEqual(e["verdict"], "abstain")
|
|
69
|
+
|
|
70
|
+
def test_no_signal_abstains(self):
|
|
71
|
+
e = self._one(score_suggestions(
|
|
72
|
+
[_iss(5, "totally unrelated billing thing")],
|
|
73
|
+
[_trk("auth-flow", milestone="v0.4")],
|
|
74
|
+
))
|
|
75
|
+
self.assertEqual(e["verdict"], "abstain")
|
|
76
|
+
self.assertNotIn("track", e)
|
|
77
|
+
|
|
78
|
+
def test_margin_narrow_when_two_tracks_tie(self):
|
|
79
|
+
# Both tracks share the milestone → equal top score → narrow margin.
|
|
80
|
+
e = self._one(score_suggestions(
|
|
81
|
+
[_iss(6, "x", milestone="v0.4")],
|
|
82
|
+
[_trk("auth-flow", milestone="v0.4"), _trk("idea-mode", milestone="v0.4")],
|
|
83
|
+
))
|
|
84
|
+
self.assertEqual(e["verdict"], "suggest")
|
|
85
|
+
self.assertEqual(e["margin"], "narrow")
|
|
86
|
+
self.assertIn("runner_up", e)
|
|
87
|
+
|
|
88
|
+
def test_margin_clear_when_one_track_dominates(self):
|
|
89
|
+
e = self._one(score_suggestions(
|
|
90
|
+
[_iss(7, "auth rate limit", milestone="v0.4", labels=["area/auth"])],
|
|
91
|
+
[_trk("auth-flow", name="auth flow", milestone="v0.4", labels=["area/auth"]),
|
|
92
|
+
_trk("idea-mode", milestone="v9.9")],
|
|
93
|
+
))
|
|
94
|
+
self.assertEqual(e["margin"], "clear")
|
|
95
|
+
self.assertEqual(e["track"], "auth-flow")
|
|
96
|
+
|
|
97
|
+
def test_confidence_clamped_and_in_range(self):
|
|
98
|
+
e = self._one(score_suggestions(
|
|
99
|
+
[_iss(8, "auth flow rate limit session token scope",
|
|
100
|
+
milestone="v0.4", labels=["area/auth"])],
|
|
101
|
+
[_trk("auth-flow", name="auth flow", milestone="v0.4",
|
|
102
|
+
scope="auth rate limit session token scope", labels=["area/auth"])],
|
|
103
|
+
))
|
|
104
|
+
self.assertLessEqual(e["confidence"], 1.0)
|
|
105
|
+
self.assertGreaterEqual(e["confidence"], 0.3)
|
|
106
|
+
|
|
107
|
+
def test_malformed_issue_number_skipped(self):
|
|
108
|
+
entries = score_suggestions([{"number": "not-an-int", "title": "x"}],
|
|
109
|
+
[_trk("a")])
|
|
110
|
+
self.assertEqual(entries, [])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
unittest.main()
|