@stylusnexus/work-plan 2026.6.9-1
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/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Tests for --auto-next sibling-claim filtering (#50 corrected scope).
|
|
2
|
+
|
|
3
|
+
`handoff --auto-next` is non-interactive on collisions: when the suggester
|
|
4
|
+
returns issues already next_up on a sibling active track in the same repo,
|
|
5
|
+
those issues are dropped silently (with a transparent "↷ skipped" line),
|
|
6
|
+
NOT prompted. The edit branch falls back to --set-next-style warn/confirm
|
|
7
|
+
because the user is explicit there.
|
|
8
|
+
"""
|
|
9
|
+
import io
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stdout
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
19
|
+
|
|
20
|
+
from commands import handoff
|
|
21
|
+
from lib.frontmatter import parse_file, write_file
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
|
|
25
|
+
next_up=None, issues=None) -> Path:
|
|
26
|
+
meta = {
|
|
27
|
+
"track": slug,
|
|
28
|
+
"status": status,
|
|
29
|
+
"launch_priority": "P1",
|
|
30
|
+
"github": {
|
|
31
|
+
"repo": repo,
|
|
32
|
+
"issues": list(issues or [100, 200, 300, 400]),
|
|
33
|
+
"branches": [],
|
|
34
|
+
},
|
|
35
|
+
"next_up": list(next_up or []),
|
|
36
|
+
}
|
|
37
|
+
body = f"\n# {slug}\n\nBody.\n"
|
|
38
|
+
path = dir_path / f"{slug}.md"
|
|
39
|
+
write_file(path, meta, body)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _open_issue(num: int, *, priority: str = "P1") -> 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
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AutoNextSkipTest(unittest.TestCase):
|
|
54
|
+
def setUp(self):
|
|
55
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
56
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
57
|
+
self.repo_dir = self.notes_root / "demo"
|
|
58
|
+
self.repo_dir.mkdir(parents=True)
|
|
59
|
+
|
|
60
|
+
self.cfg = {
|
|
61
|
+
"notes_root": str(self.notes_root),
|
|
62
|
+
"repos": {"demo": {"github": "stylusnexus/Demo"}},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
self._patches = [
|
|
66
|
+
mock.patch("commands.handoff.load_config", return_value=self.cfg),
|
|
67
|
+
mock.patch("commands.handoff.has_uncommitted", return_value=False),
|
|
68
|
+
]
|
|
69
|
+
for p in self._patches:
|
|
70
|
+
p.start()
|
|
71
|
+
|
|
72
|
+
def tearDown(self):
|
|
73
|
+
for p in self._patches:
|
|
74
|
+
p.stop()
|
|
75
|
+
self.tmp.cleanup()
|
|
76
|
+
|
|
77
|
+
def _run_auto_next(self, track_name: str, *, issues_response, prompt_answer="y"):
|
|
78
|
+
"""Run handoff --auto-next with mocked fetch_issues + prompt_input."""
|
|
79
|
+
buf = io.StringIO()
|
|
80
|
+
with mock.patch("commands.handoff.fetch_issues", return_value=issues_response), \
|
|
81
|
+
mock.patch("commands.handoff.prompt_input", return_value=prompt_answer), \
|
|
82
|
+
redirect_stdout(buf):
|
|
83
|
+
rc = handoff.run([track_name, "--auto-next"])
|
|
84
|
+
return rc, buf.getvalue()
|
|
85
|
+
|
|
86
|
+
def test_no_collisions_passes_through_full_suggestion(self):
|
|
87
|
+
"""Sibling has nothing in next_up → suggestion is unchanged."""
|
|
88
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
89
|
+
issues=[100, 200])
|
|
90
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[])
|
|
91
|
+
|
|
92
|
+
rc, out = self._run_auto_next("track-a",
|
|
93
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
94
|
+
|
|
95
|
+
self.assertEqual(rc, 0)
|
|
96
|
+
self.assertNotIn("↷ skipped", out)
|
|
97
|
+
meta, _ = parse_file(target)
|
|
98
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
99
|
+
|
|
100
|
+
def test_sibling_claimed_issue_is_skipped_with_transparent_line(self):
|
|
101
|
+
"""Sibling has #100 → #100 is dropped from suggestion, message printed."""
|
|
102
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
103
|
+
issues=[100, 200])
|
|
104
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
|
|
105
|
+
|
|
106
|
+
rc, out = self._run_auto_next("track-a",
|
|
107
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
108
|
+
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
self.assertIn("↷ skipped #100 (already next_up on 'track-b')", out)
|
|
111
|
+
meta, _ = parse_file(target)
|
|
112
|
+
self.assertEqual(meta["next_up"], [200]) # #100 dropped
|
|
113
|
+
|
|
114
|
+
def test_all_suggestions_claimed_returns_zero_unchanged(self):
|
|
115
|
+
"""Every suggested issue is sibling-claimed → next_up unchanged, rc 0."""
|
|
116
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
117
|
+
issues=[100, 200], next_up=[42])
|
|
118
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo",
|
|
119
|
+
next_up=[100, 200])
|
|
120
|
+
|
|
121
|
+
rc, out = self._run_auto_next("track-a",
|
|
122
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
123
|
+
|
|
124
|
+
self.assertEqual(rc, 0)
|
|
125
|
+
self.assertIn("All suggested issues are already next_up on sibling tracks", out)
|
|
126
|
+
meta, _ = parse_file(target)
|
|
127
|
+
self.assertEqual(meta["next_up"], [42]) # original list intact
|
|
128
|
+
|
|
129
|
+
def test_parked_sibling_does_not_filter(self):
|
|
130
|
+
"""Parked sibling holding #100 should NOT trigger a skip — parked
|
|
131
|
+
tracks don't compete for attention."""
|
|
132
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
133
|
+
issues=[100, 200])
|
|
134
|
+
_make_track(self.repo_dir, "track-parked", repo="stylusnexus/Demo",
|
|
135
|
+
status="parked", next_up=[100])
|
|
136
|
+
|
|
137
|
+
rc, out = self._run_auto_next("track-a",
|
|
138
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
139
|
+
|
|
140
|
+
self.assertEqual(rc, 0)
|
|
141
|
+
self.assertNotIn("↷ skipped", out)
|
|
142
|
+
meta, _ = parse_file(target)
|
|
143
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
144
|
+
|
|
145
|
+
def test_cross_repo_sibling_does_not_filter(self):
|
|
146
|
+
"""Sibling in a different repo holding the same issue number should
|
|
147
|
+
NOT trigger a skip — issue numbers are repo-scoped."""
|
|
148
|
+
other_dir = self.notes_root / "other"
|
|
149
|
+
other_dir.mkdir(parents=True)
|
|
150
|
+
self.cfg["repos"]["other"] = {"github": "stylusnexus/Other"}
|
|
151
|
+
|
|
152
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
153
|
+
issues=[100, 200])
|
|
154
|
+
_make_track(other_dir, "track-other", repo="stylusnexus/Other",
|
|
155
|
+
next_up=[100])
|
|
156
|
+
|
|
157
|
+
rc, out = self._run_auto_next("track-a",
|
|
158
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
159
|
+
|
|
160
|
+
self.assertEqual(rc, 0)
|
|
161
|
+
self.assertNotIn("↷ skipped", out)
|
|
162
|
+
meta, _ = parse_file(target)
|
|
163
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
164
|
+
|
|
165
|
+
def test_user_decline_at_apply_prompt_keeps_skipped_record(self):
|
|
166
|
+
"""User picks 'n' at the apply prompt → next_up unchanged, but skip
|
|
167
|
+
line was still printed (the filter ran before the prompt)."""
|
|
168
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
169
|
+
issues=[100, 200], next_up=[7])
|
|
170
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
|
|
171
|
+
|
|
172
|
+
rc, out = self._run_auto_next("track-a",
|
|
173
|
+
issues_response=[_open_issue(100), _open_issue(200)],
|
|
174
|
+
prompt_answer="n")
|
|
175
|
+
|
|
176
|
+
self.assertEqual(rc, 0)
|
|
177
|
+
self.assertIn("↷ skipped #100", out)
|
|
178
|
+
meta, _ = parse_file(target)
|
|
179
|
+
self.assertEqual(meta["next_up"], [7]) # decline preserved
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
unittest.main()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tests for cross-track next_up collision warning (#50).
|
|
2
|
+
|
|
3
|
+
When the user applies a next_up list to one track, the CLI should warn if any
|
|
4
|
+
of those issues are already next_up on a sibling active track in the same repo.
|
|
5
|
+
The prompt is read-only on local frontmatter — no GitHub calls — and respects
|
|
6
|
+
y/N: 'y' applies anyway, 'N' (default) skips the write.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
16
|
+
|
|
17
|
+
from commands import handoff
|
|
18
|
+
from lib.frontmatter import parse_file, write_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
|
|
22
|
+
next_up=None) -> Path:
|
|
23
|
+
meta = {
|
|
24
|
+
"track": slug,
|
|
25
|
+
"status": status,
|
|
26
|
+
"launch_priority": "P1",
|
|
27
|
+
"github": {"repo": repo, "issues": [100, 200, 300, 400], "branches": []},
|
|
28
|
+
"next_up": list(next_up or []),
|
|
29
|
+
}
|
|
30
|
+
body = f"\n# {slug}\n\nBody.\n"
|
|
31
|
+
path = dir_path / f"{slug}.md"
|
|
32
|
+
write_file(path, meta, body)
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CollisionWarningTest(unittest.TestCase):
|
|
37
|
+
def setUp(self):
|
|
38
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
39
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
40
|
+
self.repo_dir = self.notes_root / "demo"
|
|
41
|
+
self.repo_dir.mkdir(parents=True)
|
|
42
|
+
|
|
43
|
+
self.cfg = {
|
|
44
|
+
"notes_root": str(self.notes_root),
|
|
45
|
+
"repos": {"demo": {"github": "stylusnexus/Demo"}},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
self._patches = [
|
|
49
|
+
mock.patch("commands.handoff.load_config", return_value=self.cfg),
|
|
50
|
+
mock.patch("commands.handoff.fetch_issues", return_value=[]),
|
|
51
|
+
mock.patch("commands.handoff.has_uncommitted", return_value=False),
|
|
52
|
+
]
|
|
53
|
+
for p in self._patches:
|
|
54
|
+
p.start()
|
|
55
|
+
|
|
56
|
+
def tearDown(self):
|
|
57
|
+
for p in self._patches:
|
|
58
|
+
p.stop()
|
|
59
|
+
self.tmp.cleanup()
|
|
60
|
+
|
|
61
|
+
def test_no_collision_no_prompt(self):
|
|
62
|
+
"""No sibling holds the proposed issue → write proceeds without prompt."""
|
|
63
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
|
|
64
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[999])
|
|
65
|
+
|
|
66
|
+
with mock.patch("commands.handoff.prompt_input") as mock_prompt:
|
|
67
|
+
rc = handoff.run(["track-a", "--set-next", "100,200"])
|
|
68
|
+
|
|
69
|
+
self.assertEqual(rc, 0)
|
|
70
|
+
mock_prompt.assert_not_called()
|
|
71
|
+
meta, _ = parse_file(target)
|
|
72
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
73
|
+
|
|
74
|
+
def test_collision_user_accepts_writes(self):
|
|
75
|
+
"""Sibling holds #100 in next_up → prompt fires; 'y' writes anyway."""
|
|
76
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
|
|
77
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
|
|
78
|
+
|
|
79
|
+
with mock.patch("commands.handoff.prompt_input", return_value="y"):
|
|
80
|
+
rc = handoff.run(["track-a", "--set-next", "100,200"])
|
|
81
|
+
|
|
82
|
+
self.assertEqual(rc, 0)
|
|
83
|
+
meta, _ = parse_file(target)
|
|
84
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
85
|
+
|
|
86
|
+
def test_collision_user_declines_skips_write(self):
|
|
87
|
+
"""Sibling holds #100; user declines → next_up unchanged."""
|
|
88
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
89
|
+
next_up=[42])
|
|
90
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
|
|
91
|
+
|
|
92
|
+
with mock.patch("commands.handoff.prompt_input", return_value="n"):
|
|
93
|
+
rc = handoff.run(["track-a", "--set-next", "100,200"])
|
|
94
|
+
|
|
95
|
+
self.assertEqual(rc, 0)
|
|
96
|
+
meta, _ = parse_file(target)
|
|
97
|
+
self.assertEqual(meta["next_up"], [42]) # original list intact
|
|
98
|
+
|
|
99
|
+
def test_parked_sibling_does_not_trigger_warning(self):
|
|
100
|
+
"""A parked / abandoned track holding the issue should not flag —
|
|
101
|
+
parked tracks don't compete for attention."""
|
|
102
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
|
|
103
|
+
_make_track(self.repo_dir, "track-parked", repo="stylusnexus/Demo",
|
|
104
|
+
status="parked", next_up=[100])
|
|
105
|
+
|
|
106
|
+
with mock.patch("commands.handoff.prompt_input") as mock_prompt:
|
|
107
|
+
rc = handoff.run(["track-a", "--set-next", "100"])
|
|
108
|
+
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
mock_prompt.assert_not_called()
|
|
111
|
+
meta, _ = parse_file(target)
|
|
112
|
+
self.assertEqual(meta["next_up"], [100])
|
|
113
|
+
|
|
114
|
+
def test_cross_repo_does_not_trigger_warning(self):
|
|
115
|
+
"""A sibling in a different repo holding the same issue number should
|
|
116
|
+
not flag — issue numbers are repo-scoped."""
|
|
117
|
+
other_repo_dir = self.notes_root / "other"
|
|
118
|
+
other_repo_dir.mkdir(parents=True)
|
|
119
|
+
# Make config aware of the second repo so discover_tracks resolves it.
|
|
120
|
+
self.cfg["repos"]["other"] = {"github": "stylusnexus/Other"}
|
|
121
|
+
|
|
122
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
|
|
123
|
+
_make_track(other_repo_dir, "track-other", repo="stylusnexus/Other",
|
|
124
|
+
next_up=[100])
|
|
125
|
+
|
|
126
|
+
with mock.patch("commands.handoff.prompt_input") as mock_prompt:
|
|
127
|
+
rc = handoff.run(["track-a", "--set-next", "100"])
|
|
128
|
+
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
mock_prompt.assert_not_called()
|
|
131
|
+
meta, _ = parse_file(target)
|
|
132
|
+
self.assertEqual(meta["next_up"], [100])
|
|
133
|
+
|
|
134
|
+
def test_self_track_excluded_from_check(self):
|
|
135
|
+
"""Re-applying the same list to the SAME track must not self-collide."""
|
|
136
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
137
|
+
next_up=[100, 200])
|
|
138
|
+
|
|
139
|
+
with mock.patch("commands.handoff.prompt_input") as mock_prompt:
|
|
140
|
+
rc = handoff.run(["track-a", "--set-next", "100,200"])
|
|
141
|
+
|
|
142
|
+
self.assertEqual(rc, 0)
|
|
143
|
+
mock_prompt.assert_not_called()
|
|
144
|
+
meta, _ = parse_file(target)
|
|
145
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tests for `handoff --set-next` flag (Claude-driven next_up persistence)."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
11
|
+
|
|
12
|
+
from commands import handoff
|
|
13
|
+
from lib.frontmatter import parse_file, write_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_track_file(dir_path: Path, slug: str = "demo-track") -> Path:
|
|
17
|
+
"""Build a minimal track .md with empty next_up + frontmatter the
|
|
18
|
+
handoff command can resolve."""
|
|
19
|
+
meta = {
|
|
20
|
+
"track": slug,
|
|
21
|
+
"status": "active",
|
|
22
|
+
"launch_priority": "P1",
|
|
23
|
+
"github": {"repo": "stylusnexus/Demo", "issues": [100, 200, 300], "branches": []},
|
|
24
|
+
"next_up": [],
|
|
25
|
+
}
|
|
26
|
+
body = "\n# Demo\n\nBody content for the track.\n"
|
|
27
|
+
path = dir_path / f"{slug}.md"
|
|
28
|
+
write_file(path, meta, body)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HandoffSetNextTest(unittest.TestCase):
|
|
33
|
+
def setUp(self):
|
|
34
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
35
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
36
|
+
# Tracks live under <notes_root>/<repo-folder>/<slug>.md so config
|
|
37
|
+
# discovery + repo resolution work the same as in production.
|
|
38
|
+
self.repo_dir = self.notes_root / "demo"
|
|
39
|
+
self.repo_dir.mkdir(parents=True)
|
|
40
|
+
self.track_path = _make_track_file(self.repo_dir, "demo-track")
|
|
41
|
+
|
|
42
|
+
# Stub config so discover_tracks walks our temp notes_root.
|
|
43
|
+
self.cfg = {
|
|
44
|
+
"notes_root": str(self.notes_root),
|
|
45
|
+
"repos": {"demo": {"github": "stylusnexus/Demo"}},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Patch load_config to return our stub.
|
|
49
|
+
self._patches = [
|
|
50
|
+
mock.patch("commands.handoff.load_config", return_value=self.cfg),
|
|
51
|
+
# Skip GitHub fetch — fetch_issues hits `gh` over the network
|
|
52
|
+
# in production. Return [] so the rest of handoff runs purely
|
|
53
|
+
# off the body + frontmatter.
|
|
54
|
+
mock.patch("commands.handoff.fetch_issues", return_value=[]),
|
|
55
|
+
# Avoid scanning a real git repo — track has no local_path and
|
|
56
|
+
# current_branch isn't called when local_path is None, but be
|
|
57
|
+
# defensive in case derived_handoff drifts.
|
|
58
|
+
mock.patch("commands.handoff.has_uncommitted", return_value=False),
|
|
59
|
+
]
|
|
60
|
+
for p in self._patches:
|
|
61
|
+
p.start()
|
|
62
|
+
|
|
63
|
+
def tearDown(self):
|
|
64
|
+
for p in self._patches:
|
|
65
|
+
p.stop()
|
|
66
|
+
self.tmp.cleanup()
|
|
67
|
+
|
|
68
|
+
def test_set_next_persists_to_frontmatter(self):
|
|
69
|
+
"""--set-next 100,200,300 should write next_up: [100, 200, 300]."""
|
|
70
|
+
rc = handoff.run(["demo-track", "--set-next", "100,200,300"])
|
|
71
|
+
self.assertEqual(rc, 0)
|
|
72
|
+
meta, _ = parse_file(self.track_path)
|
|
73
|
+
self.assertEqual(meta["next_up"], [100, 200, 300])
|
|
74
|
+
|
|
75
|
+
def test_set_next_replaces_existing_list(self):
|
|
76
|
+
"""--set-next overwrites any prior next_up entries."""
|
|
77
|
+
meta, body = parse_file(self.track_path)
|
|
78
|
+
meta["next_up"] = [9999]
|
|
79
|
+
write_file(self.track_path, meta, body)
|
|
80
|
+
|
|
81
|
+
rc = handoff.run(["demo-track", "--set-next", "100,200"])
|
|
82
|
+
self.assertEqual(rc, 0)
|
|
83
|
+
meta, _ = parse_file(self.track_path)
|
|
84
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
85
|
+
|
|
86
|
+
def test_set_next_equals_form_also_works(self):
|
|
87
|
+
"""--set-next=100,200 (key=value) should behave the same as space form."""
|
|
88
|
+
rc = handoff.run(["demo-track", "--set-next=100,200"])
|
|
89
|
+
self.assertEqual(rc, 0)
|
|
90
|
+
meta, _ = parse_file(self.track_path)
|
|
91
|
+
self.assertEqual(meta["next_up"], [100, 200])
|
|
92
|
+
|
|
93
|
+
def test_set_next_rejects_non_numeric(self):
|
|
94
|
+
"""Garbage input → exit 2, frontmatter untouched."""
|
|
95
|
+
# Pre-condition: next_up is empty.
|
|
96
|
+
meta, _ = parse_file(self.track_path)
|
|
97
|
+
self.assertEqual(meta["next_up"], [])
|
|
98
|
+
|
|
99
|
+
rc = handoff.run(["demo-track", "--set-next", "not-numbers"])
|
|
100
|
+
self.assertEqual(rc, 2)
|
|
101
|
+
meta, _ = parse_file(self.track_path)
|
|
102
|
+
self.assertEqual(meta["next_up"], []) # unchanged
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|