@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.
- package/README.md +12 -10
- package/VERSION +1 -1
- package/package.json +1 -5
- 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/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/tracks.py +64 -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_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/work_plan.py +15 -5
- package/scripts/npm-check-deps.js +0 -44
|
@@ -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()
|