@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,292 @@
|
|
|
1
|
+
"""Tests for the non-interactive set-notes-root command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- set-notes-root /some/new/path → yq called with correct expression, mkdir
|
|
5
|
+
called, rc 0.
|
|
6
|
+
- Missing positional path → rc 2, no yq call.
|
|
7
|
+
- Orphan warning: current notes_root differs and discover_tracks returns ≥1
|
|
8
|
+
track → WARN line printed but rc 0 and yq still called.
|
|
9
|
+
- No warning when new path equals current notes_root.
|
|
10
|
+
- No warning when discover_tracks returns no tracks.
|
|
11
|
+
- yq failure (CalledProcessError) → rc 1.
|
|
12
|
+
- Subcommand registered in SUBCOMMANDS and DESCRIPTIONS.
|
|
13
|
+
- Non-interactive guard: input()/prompt_input patched to raise, must not fire.
|
|
14
|
+
"""
|
|
15
|
+
import io
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import unittest
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from unittest.mock import patch, MagicMock, call
|
|
22
|
+
|
|
23
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
25
|
+
|
|
26
|
+
import work_plan
|
|
27
|
+
from commands import set_notes_root
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Helpers
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _make_cfg(*, notes_root="/tmp/old-notes"):
|
|
35
|
+
return {"notes_root": notes_root, "repos": {}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fake_track():
|
|
39
|
+
"""Return a minimal Track-like object (just needs to be truthy in a list)."""
|
|
40
|
+
t = MagicMock()
|
|
41
|
+
t.has_frontmatter = True
|
|
42
|
+
return t
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _drive(args, *, cfg_notes_root="/tmp/old-notes", tracks=None, yq_raises=False):
|
|
46
|
+
"""Run set_notes_root.run(args) with all external I/O mocked.
|
|
47
|
+
|
|
48
|
+
cfg_notes_root: the current notes_root recorded in config.
|
|
49
|
+
tracks: list returned by discover_tracks (default []).
|
|
50
|
+
yq_raises: if True, subprocess.run raises CalledProcessError.
|
|
51
|
+
"""
|
|
52
|
+
cfg = _make_cfg(notes_root=cfg_notes_root)
|
|
53
|
+
if tracks is None:
|
|
54
|
+
tracks = []
|
|
55
|
+
|
|
56
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
57
|
+
|
|
58
|
+
if yq_raises:
|
|
59
|
+
err = subprocess.CalledProcessError(1, ["yq"], stderr="yq error")
|
|
60
|
+
sub_side = err
|
|
61
|
+
else:
|
|
62
|
+
sub_side = None
|
|
63
|
+
|
|
64
|
+
with patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
65
|
+
patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
|
|
66
|
+
patch("commands.set_notes_root.subprocess.run",
|
|
67
|
+
return_value=mock_proc,
|
|
68
|
+
side_effect=sub_side) as msub, \
|
|
69
|
+
patch("pathlib.Path.mkdir") as mmkdir:
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
with redirect_stdout(buf):
|
|
72
|
+
rc = set_notes_root.run(args)
|
|
73
|
+
return rc, msub, mmkdir, buf.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Test cases
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
class SetNotesRootTest(unittest.TestCase):
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Happy path: updates config and creates dir
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_happy_path_calls_yq_and_mkdir(self):
|
|
87
|
+
"""set-notes-root /some/new/path → yq -i called with the absolute path
|
|
88
|
+
expression, mkdir called, rc 0."""
|
|
89
|
+
rc, msub, mmkdir, out = _drive(
|
|
90
|
+
["/some/new/path"],
|
|
91
|
+
cfg_notes_root="/tmp/old-notes",
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
|
|
95
|
+
# yq must have been called
|
|
96
|
+
msub.assert_called_once()
|
|
97
|
+
yq_args = msub.call_args[0][0]
|
|
98
|
+
self.assertEqual(yq_args[0], "yq")
|
|
99
|
+
self.assertEqual(yq_args[1], "-i")
|
|
100
|
+
# Expression must set .notes_root to the absolute path. The command
|
|
101
|
+
# resolves the input to an absolute path, which on Windows uses a drive
|
|
102
|
+
# letter + backslashes — so compare against the same resolution, not the
|
|
103
|
+
# raw POSIX input string.
|
|
104
|
+
expected = str(Path("/some/new/path").expanduser().resolve())
|
|
105
|
+
expr = yq_args[2]
|
|
106
|
+
self.assertIn(".notes_root", expr)
|
|
107
|
+
self.assertIn(expected, expr)
|
|
108
|
+
|
|
109
|
+
# mkdir must have been called (creates the dir)
|
|
110
|
+
mmkdir.assert_called_once()
|
|
111
|
+
|
|
112
|
+
# Success confirmation in output
|
|
113
|
+
self.assertIn("✓", out)
|
|
114
|
+
self.assertIn(expected, out)
|
|
115
|
+
|
|
116
|
+
def test_yq_receives_config_path_as_last_arg(self):
|
|
117
|
+
"""yq -i call passes DEFAULT_CONFIG_PATH as the file argument."""
|
|
118
|
+
from lib.config import DEFAULT_CONFIG_PATH
|
|
119
|
+
rc, msub, mmkdir, out = _drive(["/new/path"])
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
yq_args = msub.call_args[0][0]
|
|
122
|
+
self.assertEqual(yq_args[-1], str(DEFAULT_CONFIG_PATH))
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Missing positional → rc 2
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def test_missing_path_returns_rc2(self):
|
|
129
|
+
"""No positional argument → rc 2, yq NOT called."""
|
|
130
|
+
rc, msub, mmkdir, out = _drive([])
|
|
131
|
+
self.assertEqual(rc, 2)
|
|
132
|
+
msub.assert_not_called()
|
|
133
|
+
self.assertIn("usage", out.lower())
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Orphan warning: tracks exist at old root
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_orphan_warning_when_tracks_exist_and_root_differs(self):
|
|
140
|
+
"""New path differs from current, discover_tracks returns 1 track →
|
|
141
|
+
WARN line printed, rc 0, yq still called."""
|
|
142
|
+
tracks = [_fake_track()]
|
|
143
|
+
rc, msub, mmkdir, out = _drive(
|
|
144
|
+
["/some/new/path"],
|
|
145
|
+
cfg_notes_root="/tmp/old-notes",
|
|
146
|
+
tracks=tracks,
|
|
147
|
+
)
|
|
148
|
+
self.assertEqual(rc, 0)
|
|
149
|
+
msub.assert_called_once()
|
|
150
|
+
self.assertIn("WARN", out)
|
|
151
|
+
|
|
152
|
+
def test_orphan_warning_names_count(self):
|
|
153
|
+
"""Orphan warning mentions the track count."""
|
|
154
|
+
tracks = [_fake_track(), _fake_track(), _fake_track()]
|
|
155
|
+
rc, msub, mmkdir, out = _drive(
|
|
156
|
+
["/brand/new/path"],
|
|
157
|
+
cfg_notes_root="/tmp/old-notes",
|
|
158
|
+
tracks=tracks,
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(rc, 0)
|
|
161
|
+
self.assertIn("3", out)
|
|
162
|
+
|
|
163
|
+
def test_orphan_warning_mentions_not_moved(self):
|
|
164
|
+
"""Orphan warning states tracks will NOT be moved."""
|
|
165
|
+
tracks = [_fake_track()]
|
|
166
|
+
rc, msub, mmkdir, out = _drive(
|
|
167
|
+
["/brand/new/path"],
|
|
168
|
+
cfg_notes_root="/tmp/old-notes",
|
|
169
|
+
tracks=tracks,
|
|
170
|
+
)
|
|
171
|
+
self.assertIn("WARN", out)
|
|
172
|
+
# The warning should communicate non-movement
|
|
173
|
+
out_lower = out.lower()
|
|
174
|
+
self.assertTrue(
|
|
175
|
+
"not" in out_lower or "won't" in out_lower or "manual" in out_lower,
|
|
176
|
+
f"Expected move-warning language in: {out!r}",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# No warning when new path equals current notes_root
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def test_no_warning_when_path_unchanged(self):
|
|
184
|
+
"""New path resolves to same location as current → no WARN, rc 0."""
|
|
185
|
+
tracks = [_fake_track()]
|
|
186
|
+
rc, msub, mmkdir, out = _drive(
|
|
187
|
+
["/tmp/old-notes"],
|
|
188
|
+
cfg_notes_root="/tmp/old-notes",
|
|
189
|
+
tracks=tracks,
|
|
190
|
+
)
|
|
191
|
+
self.assertEqual(rc, 0)
|
|
192
|
+
self.assertNotIn("WARN", out)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# No warning when there are no tracks
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def test_no_warning_when_no_tracks(self):
|
|
199
|
+
"""Paths differ but no tracks → no WARN, rc 0."""
|
|
200
|
+
rc, msub, mmkdir, out = _drive(
|
|
201
|
+
["/some/new/path"],
|
|
202
|
+
cfg_notes_root="/tmp/old-notes",
|
|
203
|
+
tracks=[],
|
|
204
|
+
)
|
|
205
|
+
self.assertEqual(rc, 0)
|
|
206
|
+
self.assertNotIn("WARN", out)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# yq failure → rc 1
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def test_yq_failure_returns_rc1(self):
|
|
213
|
+
"""CalledProcessError from yq → error message printed, rc 1."""
|
|
214
|
+
rc, msub, mmkdir, out = _drive(
|
|
215
|
+
["/some/new/path"],
|
|
216
|
+
yq_raises=True,
|
|
217
|
+
)
|
|
218
|
+
self.assertEqual(rc, 1)
|
|
219
|
+
self.assertIn("ERROR", out)
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Subcommand registration
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def test_subcommand_registered_in_subcommands(self):
|
|
226
|
+
"""'set-notes-root' appears in work_plan.SUBCOMMANDS."""
|
|
227
|
+
self.assertIn("set-notes-root", work_plan.SUBCOMMANDS)
|
|
228
|
+
|
|
229
|
+
def test_subcommand_registered_in_descriptions(self):
|
|
230
|
+
"""'set-notes-root' appears in work_plan.DESCRIPTIONS."""
|
|
231
|
+
names = [entry[0] for entry in work_plan.DESCRIPTIONS]
|
|
232
|
+
self.assertIn("set-notes-root", names)
|
|
233
|
+
|
|
234
|
+
def test_subcommand_module_path(self):
|
|
235
|
+
"""SUBCOMMANDS['set-notes-root'] points to commands.set_notes_root."""
|
|
236
|
+
self.assertEqual(
|
|
237
|
+
work_plan.SUBCOMMANDS["set-notes-root"],
|
|
238
|
+
"commands.set_notes_root",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Non-interactive guard
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def test_no_input_called_happy_path(self):
|
|
246
|
+
"""Happy path must not call input() or prompt_input."""
|
|
247
|
+
cfg = _make_cfg(notes_root="/tmp/old-notes")
|
|
248
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
249
|
+
|
|
250
|
+
def _raise(*a, **kw):
|
|
251
|
+
raise AssertionError(
|
|
252
|
+
"input() must not be called — command must be non-interactive"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
256
|
+
patch("lib.prompts.prompt_input", side_effect=_raise), \
|
|
257
|
+
patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
|
|
258
|
+
patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
259
|
+
patch("commands.set_notes_root.discover_tracks", return_value=[]), \
|
|
260
|
+
patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
|
|
261
|
+
patch("pathlib.Path.mkdir"):
|
|
262
|
+
buf = io.StringIO()
|
|
263
|
+
with redirect_stdout(buf):
|
|
264
|
+
rc = set_notes_root.run(["/some/path"])
|
|
265
|
+
self.assertEqual(rc, 0)
|
|
266
|
+
|
|
267
|
+
def test_no_input_called_with_tracks(self):
|
|
268
|
+
"""Orphan warning path must also not call input()."""
|
|
269
|
+
cfg = _make_cfg(notes_root="/tmp/old-notes")
|
|
270
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
271
|
+
tracks = [_fake_track()]
|
|
272
|
+
|
|
273
|
+
def _raise(*a, **kw):
|
|
274
|
+
raise AssertionError(
|
|
275
|
+
"input() must not be called — command must be non-interactive"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
279
|
+
patch("lib.prompts.prompt_input", side_effect=_raise), \
|
|
280
|
+
patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
|
|
281
|
+
patch("commands.set_notes_root.load_config", return_value=cfg), \
|
|
282
|
+
patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
|
|
283
|
+
patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
|
|
284
|
+
patch("pathlib.Path.mkdir"):
|
|
285
|
+
buf = io.StringIO()
|
|
286
|
+
with redirect_stdout(buf):
|
|
287
|
+
rc = set_notes_root.run(["/some/new/path"])
|
|
288
|
+
self.assertEqual(rc, 0)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
unittest.main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Tests for the non-interactive slot command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Slots a new issue into a private-repo track → write_file called, rc 0.
|
|
5
|
+
- Issue already present → no write, rc 0, prints "already in".
|
|
6
|
+
- Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
|
|
7
|
+
- Public repo with valid --confirm=<token> → write_file called, rc 0.
|
|
8
|
+
- --move with a prior owner → removes issue from prior owner (two writes).
|
|
9
|
+
- Default / --no-move with a prior owner → prior owner NOT modified (one write)
|
|
10
|
+
and "still listed … --move" note is printed.
|
|
11
|
+
- Bad issue number / no positional → rc 2.
|
|
12
|
+
- No input() is reached on the non-interactive flagged paths.
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
from unittest.mock import MagicMock, patch
|
|
21
|
+
|
|
22
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
24
|
+
|
|
25
|
+
from commands import slot
|
|
26
|
+
from lib.write_guard import make_token
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _track(*, name, repo="ok/repo", issues=None, status="active"):
|
|
34
|
+
return SimpleNamespace(
|
|
35
|
+
name=name,
|
|
36
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
37
|
+
body="# fake",
|
|
38
|
+
meta={
|
|
39
|
+
"track": name,
|
|
40
|
+
"status": status,
|
|
41
|
+
"github": {"repo": repo, "issues": list(issues or [])},
|
|
42
|
+
},
|
|
43
|
+
has_frontmatter=True,
|
|
44
|
+
repo=repo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _drive(args, tracks=None, vis="PRIVATE"):
|
|
49
|
+
"""Run slot.run(args) with all external I/O mocked.
|
|
50
|
+
|
|
51
|
+
vis controls what repo_visibility returns (used by needs_confirm).
|
|
52
|
+
tracks defaults to a single empty private-repo track named 'alpha'.
|
|
53
|
+
"""
|
|
54
|
+
if tracks is None:
|
|
55
|
+
tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
|
|
56
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
57
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
58
|
+
|
|
59
|
+
with patch("commands.slot.load_config", return_value=cfg), \
|
|
60
|
+
patch("commands.slot.discover_tracks", return_value=tracks), \
|
|
61
|
+
patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
62
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
63
|
+
patch("commands.slot.write_file") as mw:
|
|
64
|
+
buf = io.StringIO()
|
|
65
|
+
with redirect_stdout(buf):
|
|
66
|
+
rc = slot.run(args)
|
|
67
|
+
return rc, mw, buf.getvalue()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Test cases
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class SlotNonInteractiveTest(unittest.TestCase):
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Basic slot (private repo)
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def test_slots_new_issue_into_private_track(self):
|
|
81
|
+
"""Slots a new issue into a private-repo track → write_file called, rc 0."""
|
|
82
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
|
|
83
|
+
rc, mw, out = _drive(["30", "alpha"], tracks=[track], vis="PRIVATE")
|
|
84
|
+
self.assertEqual(rc, 0)
|
|
85
|
+
mw.assert_called_once()
|
|
86
|
+
written_meta = mw.call_args[0][1]
|
|
87
|
+
self.assertIn(30, written_meta["github"]["issues"])
|
|
88
|
+
# Issues are sorted
|
|
89
|
+
self.assertEqual(sorted(written_meta["github"]["issues"]),
|
|
90
|
+
written_meta["github"]["issues"])
|
|
91
|
+
|
|
92
|
+
def test_already_present_no_write(self):
|
|
93
|
+
"""Issue already present → no write, rc 0, prints 'already in'."""
|
|
94
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
95
|
+
rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
|
|
96
|
+
self.assertEqual(rc, 0)
|
|
97
|
+
mw.assert_not_called()
|
|
98
|
+
self.assertIn("already in", out)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Confirm-token gate (public repo)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
105
|
+
"""Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0."""
|
|
106
|
+
import json
|
|
107
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
108
|
+
rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis="PUBLIC")
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
mw.assert_not_called()
|
|
111
|
+
# Output should be parseable JSON with needs_confirm key
|
|
112
|
+
data = json.loads(out.strip())
|
|
113
|
+
self.assertTrue(data["needs_confirm"])
|
|
114
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
115
|
+
|
|
116
|
+
def test_public_repo_unknown_visibility_returns_needs_confirm_json(self):
|
|
117
|
+
"""Unknown visibility (None) → also requires confirm."""
|
|
118
|
+
import json
|
|
119
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
120
|
+
rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis=None)
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
mw.assert_not_called()
|
|
123
|
+
data = json.loads(out.strip())
|
|
124
|
+
self.assertTrue(data["needs_confirm"])
|
|
125
|
+
|
|
126
|
+
def test_public_repo_with_valid_confirm_performs_write(self):
|
|
127
|
+
"""Public repo with valid --confirm=<token> → write_file called, rc 0."""
|
|
128
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
129
|
+
tok = make_token("ok/repo", "alpha")
|
|
130
|
+
rc, mw, out = _drive(["99", "alpha", f"--confirm={tok}"],
|
|
131
|
+
tracks=[track], vis="PUBLIC")
|
|
132
|
+
self.assertEqual(rc, 0)
|
|
133
|
+
mw.assert_called_once()
|
|
134
|
+
|
|
135
|
+
def test_public_repo_with_wrong_token_blocks_write(self):
|
|
136
|
+
"""Public repo with wrong confirm token → blocked, no write."""
|
|
137
|
+
import json
|
|
138
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
139
|
+
rc, mw, out = _drive(["99", "alpha", "--confirm=badtoken"],
|
|
140
|
+
tracks=[track], vis="PUBLIC")
|
|
141
|
+
self.assertEqual(rc, 0)
|
|
142
|
+
mw.assert_not_called()
|
|
143
|
+
data = json.loads(out.strip())
|
|
144
|
+
self.assertTrue(data["needs_confirm"])
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# --move / --no-move flags
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def test_move_flag_removes_issue_from_prior_owner(self):
|
|
151
|
+
"""--move with a prior owner → removes issue from source, writes both."""
|
|
152
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
153
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
154
|
+
rc, mw, out = _drive(["42", "beta", "--move"],
|
|
155
|
+
tracks=[source, target], vis="PRIVATE")
|
|
156
|
+
self.assertEqual(rc, 0)
|
|
157
|
+
self.assertEqual(2, mw.call_count,
|
|
158
|
+
"source + target should both be written with --move")
|
|
159
|
+
self.assertNotIn(42, source.meta["github"]["issues"])
|
|
160
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
161
|
+
|
|
162
|
+
def test_default_no_move_preserves_prior_owner(self):
|
|
163
|
+
"""Default (no flags) with a prior owner → prior owner NOT modified; note printed."""
|
|
164
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
165
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
166
|
+
rc, mw, out = _drive(["42", "beta"], tracks=[source, target], vis="PRIVATE")
|
|
167
|
+
self.assertEqual(rc, 0)
|
|
168
|
+
mw.assert_called_once() # only target written
|
|
169
|
+
# Source is untouched
|
|
170
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
171
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
172
|
+
# Note about --move must be printed
|
|
173
|
+
self.assertIn("--move", out)
|
|
174
|
+
self.assertIn("alpha", out)
|
|
175
|
+
|
|
176
|
+
def test_explicit_no_move_preserves_prior_owner(self):
|
|
177
|
+
"""Explicit --no-move behaves same as default: prior owner NOT modified."""
|
|
178
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
179
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
180
|
+
rc, mw, out = _drive(["42", "beta", "--no-move"],
|
|
181
|
+
tracks=[source, target], vis="PRIVATE")
|
|
182
|
+
self.assertEqual(rc, 0)
|
|
183
|
+
mw.assert_called_once()
|
|
184
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
185
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
186
|
+
self.assertIn("--move", out)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Error cases
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def test_no_positional_args_returns_rc2(self):
|
|
193
|
+
"""No positional arguments → rc 2 (usage error)."""
|
|
194
|
+
rc, mw, out = _drive([])
|
|
195
|
+
self.assertEqual(rc, 2)
|
|
196
|
+
mw.assert_not_called()
|
|
197
|
+
|
|
198
|
+
def test_bad_issue_number_returns_rc2(self):
|
|
199
|
+
"""Non-integer issue number → rc 2."""
|
|
200
|
+
rc, mw, out = _drive(["notanumber", "alpha"])
|
|
201
|
+
self.assertEqual(rc, 2)
|
|
202
|
+
mw.assert_not_called()
|
|
203
|
+
|
|
204
|
+
def test_move_and_no_move_together_returns_rc2(self):
|
|
205
|
+
"""Passing both --move and --no-move → rc 2, write_file NOT called."""
|
|
206
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
207
|
+
rc, mw, out = _drive(["42", "alpha", "--move", "--no-move"], tracks=[track], vis="PRIVATE")
|
|
208
|
+
self.assertEqual(rc, 2)
|
|
209
|
+
mw.assert_not_called()
|
|
210
|
+
self.assertIn("ERROR", out)
|
|
211
|
+
self.assertIn("mutually exclusive", out)
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# No input() on non-interactive paths
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def test_no_input_called_on_flagged_paths(self):
|
|
218
|
+
"""Flagged paths (issue + track given) never call input() even if
|
|
219
|
+
prior owners exist or a public repo is detected."""
|
|
220
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
221
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
222
|
+
|
|
223
|
+
def _raise(*a, **kw):
|
|
224
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
225
|
+
|
|
226
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
227
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
228
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
229
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
230
|
+
with patch("commands.slot.load_config", return_value=cfg), \
|
|
231
|
+
patch("commands.slot.discover_tracks", return_value=[source, target]), \
|
|
232
|
+
patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
233
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
234
|
+
patch("commands.slot.write_file"):
|
|
235
|
+
buf = io.StringIO()
|
|
236
|
+
with redirect_stdout(buf):
|
|
237
|
+
# --move (prior owner path) + private repo (no confirm gate)
|
|
238
|
+
rc = slot.run(["42", "beta", "--move"])
|
|
239
|
+
self.assertEqual(rc, 0)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
unittest.main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tests for slot's prior-ownership detection (issue #62).
|
|
2
|
+
|
|
3
|
+
Before #62, `slot` was add-only — running `slot 4562 chat-nlu` while #4562 was
|
|
4
|
+
already listed in `ai-generators.md` frontmatter would leave the issue in BOTH
|
|
5
|
+
tracks. The only fix was hand-editing YAML, which SKILL.md explicitly warns
|
|
6
|
+
against. These tests pin the non-interactive behavior introduced in #87:
|
|
7
|
+
detect prior ownership, and move only on explicit --move so non-interactive
|
|
8
|
+
runs preserve add-only semantics by default.
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from unittest.mock import MagicMock, patch
|
|
15
|
+
|
|
16
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
18
|
+
|
|
19
|
+
from commands import slot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _track(*, name, repo, issues, status="active"):
|
|
23
|
+
return SimpleNamespace(
|
|
24
|
+
name=name,
|
|
25
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
26
|
+
body="# fake",
|
|
27
|
+
meta={
|
|
28
|
+
"track": name,
|
|
29
|
+
"status": status,
|
|
30
|
+
"github": {"repo": repo, "issues": list(issues)},
|
|
31
|
+
},
|
|
32
|
+
has_frontmatter=True,
|
|
33
|
+
repo=repo,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SlotMoveTest(unittest.TestCase):
|
|
38
|
+
def _drive(self, *, tracks, args):
|
|
39
|
+
cfg = {"notes_root": "/tmp/fake-notes",
|
|
40
|
+
"repos": {"ok": {"github": "ok/ok"}}}
|
|
41
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
42
|
+
with patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
43
|
+
patch("commands.slot.load_config", return_value=cfg), \
|
|
44
|
+
patch("commands.slot.discover_tracks", return_value=tracks), \
|
|
45
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
46
|
+
patch("commands.slot.write_file") as mw:
|
|
47
|
+
rc = slot.run(args)
|
|
48
|
+
return rc, mw
|
|
49
|
+
|
|
50
|
+
def test_no_prior_ownership_writes_only_target(self):
|
|
51
|
+
target = _track(name="alpha", repo="ok/ok", issues=[])
|
|
52
|
+
rc, mw = self._drive(tracks=[target], args=["100", "alpha"])
|
|
53
|
+
self.assertEqual(rc, 0)
|
|
54
|
+
mw.assert_called_once()
|
|
55
|
+
self.assertEqual(100, target.meta["github"]["issues"][0])
|
|
56
|
+
|
|
57
|
+
def test_prior_ownership_with_move_flag_removes_from_source(self):
|
|
58
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
59
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
60
|
+
rc, mw = self._drive(
|
|
61
|
+
tracks=[source, target], args=["42", "beta", "--move"],
|
|
62
|
+
)
|
|
63
|
+
self.assertEqual(rc, 0)
|
|
64
|
+
self.assertEqual(2, mw.call_count, "source + target should both be written")
|
|
65
|
+
self.assertEqual([], source.meta["github"]["issues"])
|
|
66
|
+
self.assertEqual([42], target.meta["github"]["issues"])
|
|
67
|
+
|
|
68
|
+
def test_prior_ownership_without_move_flag_preserves_add_only(self):
|
|
69
|
+
# Default (no --move flag) → pre-#62 behavior: target gets the issue,
|
|
70
|
+
# source is untouched (duplicated state that reconcile can later FLAG).
|
|
71
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
72
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
73
|
+
rc, mw = self._drive(
|
|
74
|
+
tracks=[source, target], args=["42", "beta"],
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(rc, 0)
|
|
77
|
+
mw.assert_called_once()
|
|
78
|
+
self.assertEqual([42], source.meta["github"]["issues"])
|
|
79
|
+
self.assertEqual([42], target.meta["github"]["issues"])
|
|
80
|
+
|
|
81
|
+
def test_explicit_no_move_flag_preserves_add_only(self):
|
|
82
|
+
# --no-move behaves identically to the default.
|
|
83
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
84
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
85
|
+
rc, mw = self._drive(
|
|
86
|
+
tracks=[source, target], args=["42", "beta", "--no-move"],
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(rc, 0)
|
|
89
|
+
mw.assert_called_once()
|
|
90
|
+
self.assertEqual([42], source.meta["github"]["issues"])
|
|
91
|
+
|
|
92
|
+
def test_already_in_target_short_circuits(self):
|
|
93
|
+
# Issue already listed in target → no write.
|
|
94
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
95
|
+
target = _track(name="beta", repo="ok/ok", issues=[42])
|
|
96
|
+
rc, mw = self._drive(
|
|
97
|
+
tracks=[source, target], args=["42", "beta"],
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual(rc, 0)
|
|
100
|
+
mw.assert_not_called()
|
|
101
|
+
|
|
102
|
+
def test_cross_repo_issue_not_detected_as_prior_owner(self):
|
|
103
|
+
# Same number in a different repo is a different issue. The prior-
|
|
104
|
+
# owner sweep MUST filter by track.repo or it will spuriously offer
|
|
105
|
+
# to move unrelated issues across repos.
|
|
106
|
+
other_repo = _track(name="alpha", repo="other/repo", issues=[42])
|
|
107
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
108
|
+
rc, mw = self._drive(
|
|
109
|
+
tracks=[other_repo, target], args=["42", "beta"],
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(rc, 0)
|
|
112
|
+
mw.assert_called_once()
|
|
113
|
+
self.assertEqual([42], other_repo.meta["github"]["issues"])
|
|
114
|
+
|
|
115
|
+
def test_inactive_source_not_detected_as_prior_owner(self):
|
|
116
|
+
# Archived/parked tracks shouldn't be candidates — moving FROM a
|
|
117
|
+
# closed track is the wrong mental model; that's a reopen, not a slot.
|
|
118
|
+
parked = _track(name="alpha", repo="ok/ok", issues=[42], status="parked")
|
|
119
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
120
|
+
rc, mw = self._drive(
|
|
121
|
+
tracks=[parked, target], args=["42", "beta"],
|
|
122
|
+
)
|
|
123
|
+
self.assertEqual(rc, 0)
|
|
124
|
+
self.assertEqual([42], parked.meta["github"]["issues"])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
unittest.main()
|