@stylusnexus/work-plan 2026.6.9
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 +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -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 +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -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 +82 -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 +40 -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/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 +109 -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_close.py +273 -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_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -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_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 +251 -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 +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -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 +166 -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_tracks.py +56 -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 +210 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tests for handoff commit-attribution helpers: path-glob attribution
|
|
2
|
+
(`github.paths`) and the repo-wide commit counter that drives the soft
|
|
3
|
+
'silence is expected' signal.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from commands import handoff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _track(meta_github, local_path="/tmp/repo"):
|
|
20
|
+
return SimpleNamespace(
|
|
21
|
+
meta={"github": meta_github},
|
|
22
|
+
local_path=local_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _proc(stdout="", returncode=0):
|
|
27
|
+
return SimpleNamespace(stdout=stdout, returncode=returncode, stderr="")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SINCE = datetime(2026, 4, 29, 0, 0, 0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RecentCommitsPathGlobsTest(unittest.TestCase):
|
|
34
|
+
def test_path_glob_attributes_commit_with_no_issue_ref(self):
|
|
35
|
+
"""A commit whose subject doesn't mention any tracked issue but
|
|
36
|
+
whose changed paths match `github.paths` should be attributed."""
|
|
37
|
+
log_output = (
|
|
38
|
+
"---COMMIT---\nabc1234|fix(useToast): debounce stacking|2026-04-30T10:00:00+00:00\n"
|
|
39
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
40
|
+
"apps/web/src/hooks/useToast.tsx\napps/web/src/hooks/useToast.test.tsx\n\n"
|
|
41
|
+
"---COMMIT---\ndef5678|chore: bump deps|2026-04-30T09:00:00+00:00\n"
|
|
42
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
43
|
+
"package.json\n"
|
|
44
|
+
)
|
|
45
|
+
track = _track({
|
|
46
|
+
"issues": [4148, 4149],
|
|
47
|
+
"paths": ["apps/web/src/hooks/useToast*"],
|
|
48
|
+
})
|
|
49
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
50
|
+
return_value=_proc(stdout=log_output)):
|
|
51
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
52
|
+
self.assertEqual(len(commits), 1)
|
|
53
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
54
|
+
|
|
55
|
+
def test_issue_ref_still_attributes_when_paths_set(self):
|
|
56
|
+
"""Issue-ref attribution and path attribution are an OR, not AND."""
|
|
57
|
+
log_output = (
|
|
58
|
+
"---COMMIT---\nabc1234|fix #4148: tighten guardrails|2026-04-30T10:00:00+00:00\n"
|
|
59
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
60
|
+
"infra/iam/policy.tf\n"
|
|
61
|
+
)
|
|
62
|
+
track = _track({
|
|
63
|
+
"issues": [4148],
|
|
64
|
+
"paths": ["apps/web/src/hooks/useToast*"],
|
|
65
|
+
})
|
|
66
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
67
|
+
return_value=_proc(stdout=log_output)):
|
|
68
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
69
|
+
self.assertEqual(len(commits), 1)
|
|
70
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
71
|
+
|
|
72
|
+
def test_body_issue_ref_attributes_squash_merge_commit(self):
|
|
73
|
+
"""Squash-merged PRs use Conventional Commit subjects with the issue
|
|
74
|
+
ref in the body (e.g. 'Closes #4148'). Subject scanning alone misses
|
|
75
|
+
these; body scanning must catch them."""
|
|
76
|
+
log_output = (
|
|
77
|
+
"---COMMIT---\nabc1234|feat(adventure): cache regen prompts|2026-04-30T10:00:00+00:00\n"
|
|
78
|
+
"---BODY---\n"
|
|
79
|
+
"Reduce duplicate LLM calls when artifacts regenerate.\n"
|
|
80
|
+
"\n"
|
|
81
|
+
"Closes #4148\n"
|
|
82
|
+
"---ENDBODY---\n"
|
|
83
|
+
)
|
|
84
|
+
track = _track({"issues": [4148]})
|
|
85
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
86
|
+
return_value=_proc(stdout=log_output)):
|
|
87
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
88
|
+
self.assertEqual(len(commits), 1)
|
|
89
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
90
|
+
|
|
91
|
+
def test_body_ref_to_untracked_issue_does_not_attribute(self):
|
|
92
|
+
"""A body that references an issue NOT in github.issues must not
|
|
93
|
+
attribute — otherwise any commit citing any issue would get picked up."""
|
|
94
|
+
log_output = (
|
|
95
|
+
"---COMMIT---\nabc1234|chore: unrelated|2026-04-30T10:00:00+00:00\n"
|
|
96
|
+
"---BODY---\nCloses #9999\n---ENDBODY---\n"
|
|
97
|
+
)
|
|
98
|
+
track = _track({"issues": [4148]})
|
|
99
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
100
|
+
return_value=_proc(stdout=log_output)):
|
|
101
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
102
|
+
self.assertEqual(commits, [])
|
|
103
|
+
|
|
104
|
+
def test_no_paths_no_issues_returns_empty(self):
|
|
105
|
+
"""A track with neither tracked issues nor path globs gets nothing."""
|
|
106
|
+
track = _track({"issues": [], "paths": []})
|
|
107
|
+
with mock.patch("commands.handoff.subprocess.run") as run:
|
|
108
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
109
|
+
run.assert_not_called()
|
|
110
|
+
self.assertEqual(commits, [])
|
|
111
|
+
|
|
112
|
+
def test_explicit_branches_skip_path_globs(self):
|
|
113
|
+
"""When `github.branches` is set, paths do not apply (explicit
|
|
114
|
+
branches are the contract)."""
|
|
115
|
+
log_output = "abc1234|merge: feature work|2026-04-30T10:00:00+00:00"
|
|
116
|
+
track = _track({
|
|
117
|
+
"issues": [4148],
|
|
118
|
+
"branches": ["feature/x"],
|
|
119
|
+
"paths": ["should-not-apply/**"],
|
|
120
|
+
})
|
|
121
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
122
|
+
return_value=_proc(stdout=log_output)) as run:
|
|
123
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
124
|
+
args = run.call_args.args[0]
|
|
125
|
+
self.assertIn("feature/x", args)
|
|
126
|
+
self.assertNotIn("--name-only", args)
|
|
127
|
+
self.assertEqual(len(commits), 1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RepoCommitsSinceTest(unittest.TestCase):
|
|
131
|
+
def test_counts_lines_in_log_output(self):
|
|
132
|
+
out = "sha1\nsha2\nsha3\n"
|
|
133
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
134
|
+
return_value=_proc(stdout=out)):
|
|
135
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
136
|
+
self.assertEqual(n, 3)
|
|
137
|
+
|
|
138
|
+
def test_returns_zero_on_empty(self):
|
|
139
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
140
|
+
return_value=_proc(stdout="")):
|
|
141
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
142
|
+
self.assertEqual(n, 0)
|
|
143
|
+
|
|
144
|
+
def test_returns_zero_on_failure(self):
|
|
145
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
146
|
+
return_value=_proc(stdout="", returncode=128)):
|
|
147
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
148
|
+
self.assertEqual(n, 0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
unittest.main()
|
|
@@ -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()
|