@stylusnexus/work-plan 2026.6.10 → 2026.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +6 -4
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/prompts.py +12 -1
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +9 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +97 -17
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Tests for the notes-vcs command + the dispatcher auto-commit hook (#103).
|
|
2
|
+
|
|
3
|
+
All git and yq calls are mocked — offline, no real repo touched.
|
|
4
|
+
"""
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch, MagicMock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
import work_plan
|
|
16
|
+
from commands import notes_vcs as cmd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cfg(notes_root="/tmp/notes", auto=None):
|
|
20
|
+
c = {"notes_root": notes_root, "repos": {}}
|
|
21
|
+
if auto is not None:
|
|
22
|
+
c["notes_vcs"] = {"auto_commit": auto}
|
|
23
|
+
return c
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _drive(args, *, cfg=None, is_root=False, is_under=False,
|
|
27
|
+
last="ab12 subject", set_ok=True, init_ok=True, dirty=False,
|
|
28
|
+
sha="ab12", revert_sha="rev99", remotes=False, owned=True,
|
|
29
|
+
parent=None):
|
|
30
|
+
cfg = cfg or _cfg()
|
|
31
|
+
with patch("commands.notes_vcs.load_config", return_value=cfg), \
|
|
32
|
+
patch("commands.notes_vcs.notes_vcs.is_git_root", return_value=is_root), \
|
|
33
|
+
patch("commands.notes_vcs.notes_vcs.is_under_git", return_value=is_under), \
|
|
34
|
+
patch("commands.notes_vcs.notes_vcs.has_remotes", return_value=remotes), \
|
|
35
|
+
patch("commands.notes_vcs.notes_vcs.is_owned", return_value=owned), \
|
|
36
|
+
patch("commands.notes_vcs.notes_vcs.last_commit_summary", return_value=last), \
|
|
37
|
+
patch("commands.notes_vcs.notes_vcs.last_commit_sha", return_value=sha), \
|
|
38
|
+
patch("commands.notes_vcs.notes_vcs.head_parent_sha", return_value=parent), \
|
|
39
|
+
patch("commands.notes_vcs.notes_vcs.has_changes", return_value=dirty), \
|
|
40
|
+
patch("commands.notes_vcs.notes_vcs.init_repo", return_value=init_ok), \
|
|
41
|
+
patch("commands.notes_vcs.notes_vcs.revert", return_value=revert_sha) as mrev, \
|
|
42
|
+
patch("commands.notes_vcs._set_auto_commit", return_value=set_ok) as mset:
|
|
43
|
+
buf = io.StringIO()
|
|
44
|
+
with redirect_stdout(buf):
|
|
45
|
+
rc = cmd.run(args)
|
|
46
|
+
return rc, buf.getvalue(), mset, mrev
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotesVcsStatusTest(unittest.TestCase):
|
|
50
|
+
def test_status_repo_on(self):
|
|
51
|
+
rc, out, _, _ = _drive(["status"], cfg=_cfg(auto=True), is_root=True)
|
|
52
|
+
self.assertEqual(rc, 0)
|
|
53
|
+
self.assertIn("local repo", out)
|
|
54
|
+
self.assertIn("auto-commit: on", out)
|
|
55
|
+
|
|
56
|
+
def test_status_not_a_repo(self):
|
|
57
|
+
rc, out, _, _ = _drive(["status"], cfg=_cfg(auto=False), is_root=False)
|
|
58
|
+
self.assertEqual(rc, 0)
|
|
59
|
+
self.assertIn("not a repo", out)
|
|
60
|
+
self.assertIn("auto-commit: off", out)
|
|
61
|
+
|
|
62
|
+
def test_status_inside_other_repo(self):
|
|
63
|
+
rc, out, _, _ = _drive(["status"], is_root=False, is_under=True)
|
|
64
|
+
self.assertEqual(rc, 0)
|
|
65
|
+
self.assertIn("NOT its root", out)
|
|
66
|
+
|
|
67
|
+
def test_default_action_is_status(self):
|
|
68
|
+
rc, out, _, _ = _drive([], is_root=True)
|
|
69
|
+
self.assertEqual(rc, 0)
|
|
70
|
+
self.assertIn("notes_root:", out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NotesVcsInitTest(unittest.TestCase):
|
|
74
|
+
def test_init_enables_by_default(self):
|
|
75
|
+
rc, out, mset, _ = _drive(["init"], is_root=False, is_under=False)
|
|
76
|
+
self.assertEqual(rc, 0)
|
|
77
|
+
self.assertIn("Initialized local history", out)
|
|
78
|
+
mset.assert_called_once_with(True)
|
|
79
|
+
self.assertIn("auto-commit enabled", out)
|
|
80
|
+
|
|
81
|
+
def test_init_no_enable_skips_toggle(self):
|
|
82
|
+
rc, out, mset, _ = _drive(["init", "--no-enable"], is_root=False)
|
|
83
|
+
self.assertEqual(rc, 0)
|
|
84
|
+
mset.assert_not_called()
|
|
85
|
+
self.assertIn("left off", out)
|
|
86
|
+
|
|
87
|
+
def test_init_refuses_inside_other_repo(self):
|
|
88
|
+
rc, out, mset, _ = _drive(["init"], is_root=False, is_under=True)
|
|
89
|
+
self.assertEqual(rc, 1)
|
|
90
|
+
self.assertIn("not its root", out.lower())
|
|
91
|
+
mset.assert_not_called()
|
|
92
|
+
|
|
93
|
+
def test_init_fails_when_init_repo_fails(self):
|
|
94
|
+
rc, out, mset, _ = _drive(["init"], is_root=False, is_under=False, init_ok=False)
|
|
95
|
+
self.assertEqual(rc, 1)
|
|
96
|
+
self.assertIn("failed to git-init", out)
|
|
97
|
+
|
|
98
|
+
def test_init_refuses_existing_repo_with_remote(self):
|
|
99
|
+
# An existing repo with a remote must be rejected — private notes must
|
|
100
|
+
# never be pushable (Codex high #1).
|
|
101
|
+
rc, out, mset, _ = _drive(["init"], is_root=True, remotes=True)
|
|
102
|
+
self.assertEqual(rc, 1)
|
|
103
|
+
self.assertIn("remote", out.lower())
|
|
104
|
+
mset.assert_not_called()
|
|
105
|
+
|
|
106
|
+
def test_init_refuses_existing_unowned_repo(self):
|
|
107
|
+
# An existing repo work-plan didn't create must not be adopted.
|
|
108
|
+
rc, out, mset, _ = _drive(["init"], is_root=True, remotes=False, owned=False)
|
|
109
|
+
self.assertEqual(rc, 1)
|
|
110
|
+
self.assertIn("not created by work-plan", out)
|
|
111
|
+
mset.assert_not_called()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class NotesVcsToggleTest(unittest.TestCase):
|
|
115
|
+
def test_enable(self):
|
|
116
|
+
rc, out, mset, _ = _drive(["enable"], is_root=True)
|
|
117
|
+
self.assertEqual(rc, 0)
|
|
118
|
+
mset.assert_called_once_with(True)
|
|
119
|
+
|
|
120
|
+
def test_enable_warns_when_not_repo(self):
|
|
121
|
+
rc, out, mset, _ = _drive(["enable"], is_root=False)
|
|
122
|
+
self.assertEqual(rc, 0)
|
|
123
|
+
self.assertIn("WARN", out)
|
|
124
|
+
|
|
125
|
+
def test_disable(self):
|
|
126
|
+
rc, out, mset, _ = _drive(["disable"], is_root=True)
|
|
127
|
+
self.assertEqual(rc, 0)
|
|
128
|
+
mset.assert_called_once_with(False)
|
|
129
|
+
|
|
130
|
+
def test_unknown_action_rc2(self):
|
|
131
|
+
rc, out, _, _ = _drive(["frobnicate"])
|
|
132
|
+
self.assertEqual(rc, 2)
|
|
133
|
+
self.assertIn("usage", out.lower())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NotesVcsStatusJsonTest(unittest.TestCase):
|
|
137
|
+
def test_json_shape(self):
|
|
138
|
+
import json
|
|
139
|
+
rc, out, _, _ = _drive(["status", "--json"], cfg=_cfg(auto=True),
|
|
140
|
+
is_root=True, is_under=True, sha="ab12",
|
|
141
|
+
last="ab12 subject", dirty=False, parent="pa01")
|
|
142
|
+
self.assertEqual(rc, 0)
|
|
143
|
+
blob = json.loads(out)
|
|
144
|
+
self.assertEqual(blob["auto_commit"], True)
|
|
145
|
+
self.assertEqual(blob["is_root"], True)
|
|
146
|
+
self.assertEqual(blob["under_git"], True)
|
|
147
|
+
self.assertEqual(blob["last_commit_sha"], "ab12")
|
|
148
|
+
self.assertEqual(blob["head_parent_sha"], "pa01")
|
|
149
|
+
self.assertEqual(blob["dirty"], False)
|
|
150
|
+
|
|
151
|
+
def test_json_nulls_when_not_repo(self):
|
|
152
|
+
import json
|
|
153
|
+
rc, out, _, _ = _drive(["status", "--json"], cfg=_cfg(auto=False),
|
|
154
|
+
is_root=False, is_under=False)
|
|
155
|
+
blob = json.loads(out)
|
|
156
|
+
self.assertEqual(blob["is_root"], False)
|
|
157
|
+
self.assertIsNone(blob["last_commit_sha"])
|
|
158
|
+
self.assertEqual(blob["auto_commit"], False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class NotesVcsUndoTest(unittest.TestCase):
|
|
162
|
+
def test_undo_head_default(self):
|
|
163
|
+
rc, out, _, mrev = _drive(["undo"], is_root=True, revert_sha="rev99")
|
|
164
|
+
self.assertEqual(rc, 0)
|
|
165
|
+
mrev.assert_called_once()
|
|
166
|
+
# default sha is None → revert(notes_root, None)
|
|
167
|
+
self.assertIsNone(mrev.call_args[0][1])
|
|
168
|
+
self.assertIn("rev99", out)
|
|
169
|
+
|
|
170
|
+
def test_undo_named_sha(self):
|
|
171
|
+
rc, out, _, mrev = _drive(["undo", "abc1234"], is_root=True,
|
|
172
|
+
revert_sha="rev88")
|
|
173
|
+
self.assertEqual(rc, 0)
|
|
174
|
+
self.assertEqual(mrev.call_args[0][1], "abc1234")
|
|
175
|
+
|
|
176
|
+
def test_undo_refuses_when_not_repo(self):
|
|
177
|
+
rc, out, _, mrev = _drive(["undo"], is_root=False)
|
|
178
|
+
self.assertEqual(rc, 1)
|
|
179
|
+
mrev.assert_not_called()
|
|
180
|
+
self.assertIn("not a git repo", out)
|
|
181
|
+
|
|
182
|
+
def test_undo_refuses_remote_backed_repo(self):
|
|
183
|
+
rc, out, _, mrev = _drive(["undo"], is_root=True, remotes=True)
|
|
184
|
+
self.assertEqual(rc, 1)
|
|
185
|
+
mrev.assert_not_called()
|
|
186
|
+
self.assertIn("local-history repo", out)
|
|
187
|
+
|
|
188
|
+
def test_undo_refuses_unowned_repo(self):
|
|
189
|
+
rc, out, _, mrev = _drive(["undo"], is_root=True, remotes=False, owned=False)
|
|
190
|
+
self.assertEqual(rc, 1)
|
|
191
|
+
mrev.assert_not_called()
|
|
192
|
+
self.assertIn("local-history repo", out)
|
|
193
|
+
|
|
194
|
+
def test_undo_fails_when_revert_fails(self):
|
|
195
|
+
rc, out, _, mrev = _drive(["undo"], is_root=True, revert_sha=None)
|
|
196
|
+
self.assertEqual(rc, 1)
|
|
197
|
+
self.assertIn("failed to revert", out)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class RegistrationTest(unittest.TestCase):
|
|
201
|
+
def test_in_subcommands(self):
|
|
202
|
+
self.assertEqual(work_plan.SUBCOMMANDS["notes-vcs"], "commands.notes_vcs")
|
|
203
|
+
|
|
204
|
+
def test_in_descriptions(self):
|
|
205
|
+
names = [e[0] for e in work_plan.DESCRIPTIONS]
|
|
206
|
+
self.assertIn("notes-vcs", names)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class DispatcherHookTest(unittest.TestCase):
|
|
210
|
+
"""The two-phase auto-commit hook: snapshot dirty paths BEFORE the command
|
|
211
|
+
(_notes_precommit_state), then commit ONLY the paths it changed
|
|
212
|
+
(_commit_changed_notes). Gated on opt-in + owned + no-remote; never raises.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def _run_dispatch(self, sub, parts, *, cfg, is_root=True, is_under=False,
|
|
216
|
+
owned=True, remotes=False, before=None, after=None,
|
|
217
|
+
sha="c0ffee"):
|
|
218
|
+
before = set() if before is None else set(before)
|
|
219
|
+
after = before if after is None else set(after)
|
|
220
|
+
dirty_seq = [before, after]
|
|
221
|
+
with patch("lib.config.load_config", return_value=cfg), \
|
|
222
|
+
patch("lib.notes_vcs.is_git_root", return_value=is_root), \
|
|
223
|
+
patch("lib.notes_vcs.is_under_git", return_value=is_under), \
|
|
224
|
+
patch("lib.notes_vcs.is_owned", return_value=owned), \
|
|
225
|
+
patch("lib.notes_vcs.has_remotes", return_value=remotes), \
|
|
226
|
+
patch("lib.notes_vcs.dirty_paths", side_effect=lambda _r: dirty_seq.pop(0)), \
|
|
227
|
+
patch("lib.notes_vcs.auto_commit", return_value=sha) as mac:
|
|
228
|
+
err = io.StringIO()
|
|
229
|
+
with redirect_stderr(err):
|
|
230
|
+
pre = work_plan._notes_precommit_state(sub)
|
|
231
|
+
if pre is not None:
|
|
232
|
+
work_plan._commit_changed_notes(pre, parts)
|
|
233
|
+
return err.getvalue(), mac, pre
|
|
234
|
+
|
|
235
|
+
def test_commits_only_paths_changed_by_the_command(self):
|
|
236
|
+
out, mac, pre = self._run_dispatch(
|
|
237
|
+
"slot", ["slot", "103", "tabletop"], cfg=_cfg(auto=True),
|
|
238
|
+
before=set(), after={"tabletop.md"})
|
|
239
|
+
self.assertIsNotNone(pre)
|
|
240
|
+
mac.assert_called_once()
|
|
241
|
+
self.assertEqual(mac.call_args[0][1], "work-plan slot 103 tabletop")
|
|
242
|
+
self.assertEqual(mac.call_args[1]["paths"], ["tabletop.md"])
|
|
243
|
+
self.assertIn("committed c0ffee", out)
|
|
244
|
+
|
|
245
|
+
def test_preserves_preexisting_dirty_files(self):
|
|
246
|
+
# A file dirty BEFORE the command stays out of the commit.
|
|
247
|
+
out, mac, _ = self._run_dispatch(
|
|
248
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=True),
|
|
249
|
+
before={"manual.md"}, after={"manual.md", "t.md"})
|
|
250
|
+
mac.assert_called_once()
|
|
251
|
+
self.assertEqual(mac.call_args[1]["paths"], ["t.md"])
|
|
252
|
+
|
|
253
|
+
def test_noop_when_command_changed_nothing(self):
|
|
254
|
+
out, mac, _ = self._run_dispatch(
|
|
255
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=True),
|
|
256
|
+
before={"manual.md"}, after={"manual.md"})
|
|
257
|
+
mac.assert_not_called()
|
|
258
|
+
|
|
259
|
+
def test_skips_read_only_command(self):
|
|
260
|
+
out, mac, pre = self._run_dispatch(
|
|
261
|
+
"brief", ["brief"], cfg=_cfg(auto=True), after={"x.md"})
|
|
262
|
+
self.assertIsNone(pre)
|
|
263
|
+
mac.assert_not_called()
|
|
264
|
+
|
|
265
|
+
def test_skips_read_only_flag_alias(self):
|
|
266
|
+
out, mac, pre = self._run_dispatch(
|
|
267
|
+
"--brief", ["--brief"], cfg=_cfg(auto=True), after={"x.md"})
|
|
268
|
+
self.assertIsNone(pre)
|
|
269
|
+
mac.assert_not_called()
|
|
270
|
+
|
|
271
|
+
def test_skips_when_disabled(self):
|
|
272
|
+
out, mac, pre = self._run_dispatch(
|
|
273
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=False), after={"x.md"})
|
|
274
|
+
self.assertIsNone(pre)
|
|
275
|
+
mac.assert_not_called()
|
|
276
|
+
|
|
277
|
+
def test_skips_when_unowned(self):
|
|
278
|
+
out, mac, pre = self._run_dispatch(
|
|
279
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=True), owned=False,
|
|
280
|
+
after={"x.md"})
|
|
281
|
+
self.assertIsNone(pre)
|
|
282
|
+
mac.assert_not_called()
|
|
283
|
+
|
|
284
|
+
def test_skips_when_remote_backed(self):
|
|
285
|
+
out, mac, pre = self._run_dispatch(
|
|
286
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=True), remotes=True,
|
|
287
|
+
after={"x.md"})
|
|
288
|
+
self.assertIsNone(pre)
|
|
289
|
+
mac.assert_not_called()
|
|
290
|
+
|
|
291
|
+
def test_nudges_when_enabled_but_not_repo(self):
|
|
292
|
+
out, mac, pre = self._run_dispatch(
|
|
293
|
+
"slot", ["slot", "1", "t"], cfg=_cfg(auto=True), is_root=False,
|
|
294
|
+
is_under=False, after={"x.md"})
|
|
295
|
+
self.assertIsNone(pre)
|
|
296
|
+
mac.assert_not_called()
|
|
297
|
+
self.assertIn("notes-vcs init", out)
|
|
298
|
+
|
|
299
|
+
def test_skips_self(self):
|
|
300
|
+
# notes-vcs manages its own repo; the hook must not double-commit.
|
|
301
|
+
with patch("lib.config.load_config") as mload:
|
|
302
|
+
pre = work_plan._notes_precommit_state("notes-vcs")
|
|
303
|
+
self.assertIsNone(pre)
|
|
304
|
+
mload.assert_not_called()
|
|
305
|
+
|
|
306
|
+
def test_precommit_never_raises_on_failure(self):
|
|
307
|
+
with patch("lib.config.load_config", side_effect=RuntimeError("boom")):
|
|
308
|
+
self.assertIsNone(work_plan._notes_precommit_state("slot"))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
unittest.main()
|
|
@@ -41,7 +41,7 @@ class IssuesTest(unittest.TestCase):
|
|
|
41
41
|
def test_draft_previews_without_creating(self):
|
|
42
42
|
with tempfile.TemporaryDirectory() as d:
|
|
43
43
|
root = self._repo(d)
|
|
44
|
-
rc, out, ci = self._run(root, ["--issues", "--draft", "--repo=
|
|
44
|
+
rc, out, ci = self._run(root, ["--issues", "--draft", "--repo=myproject"])
|
|
45
45
|
self.assertEqual(rc, 0)
|
|
46
46
|
self.assertIn("gone.ts", out) # unsatisfied path shown in preview
|
|
47
47
|
ci.assert_not_called()
|
|
@@ -49,7 +49,7 @@ class IssuesTest(unittest.TestCase):
|
|
|
49
49
|
def test_apply_creates_issue_after_confirm(self):
|
|
50
50
|
with tempfile.TemporaryDirectory() as d:
|
|
51
51
|
root = self._repo(d)
|
|
52
|
-
rc, out, ci = self._run(root, ["--issues", "--repo=
|
|
52
|
+
rc, out, ci = self._run(root, ["--issues", "--repo=myproject"])
|
|
53
53
|
self.assertEqual(rc, 0)
|
|
54
54
|
ci.assert_called_once()
|
|
55
55
|
title, body = ci.call_args[0][1], ci.call_args[0][2]
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""Tests for refresh-md
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
"""Tests for refresh-md.
|
|
2
|
+
|
|
3
|
+
Canonical tables are RE-DERIVED on every run from frontmatter membership + live
|
|
4
|
+
GitHub data, milestone-ordered via the shared renderer (#101). This makes the
|
|
5
|
+
markdown table self-healing: order, the Milestone column, missing rows, and
|
|
6
|
+
statuses are all rebuilt each run, so it can't decay or drift from the viewer.
|
|
7
|
+
Tracks WITHOUT a canonical table keep the conservative in-place behavior
|
|
8
|
+
(update status cells, append missing rows in frontmatter order — issue #77).
|
|
8
9
|
"""
|
|
9
10
|
import io
|
|
10
11
|
import sys
|
|
@@ -12,86 +13,183 @@ import unittest
|
|
|
12
13
|
from contextlib import redirect_stdout
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from types import SimpleNamespace
|
|
15
|
-
from unittest.mock import
|
|
16
|
+
from unittest.mock import patch
|
|
16
17
|
|
|
17
18
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
18
19
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
19
20
|
|
|
20
21
|
from commands import refresh_md
|
|
21
|
-
from lib.status_table import
|
|
22
|
-
|
|
23
|
-
CANON_HEADER = (
|
|
24
|
-
"## Issues (canonical)\n\n"
|
|
25
|
-
"<!-- canonical-issue-table — auto-managed. -->\n\n"
|
|
26
|
-
"| # | Title | Assignee | Status |\n"
|
|
27
|
-
"|---|---|---|---|\n"
|
|
22
|
+
from lib.status_table import (
|
|
23
|
+
find_canonical_status_tables, render_canonical_table, ISSUE_NUM_RE,
|
|
28
24
|
)
|
|
29
25
|
|
|
30
26
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"github": {"repo": repo, "issues": list(issues)}},
|
|
39
|
-
has_frontmatter=True,
|
|
40
|
-
repo=repo,
|
|
41
|
-
)
|
|
27
|
+
def _gh(num, title, state="OPEN", logins=(), milestone=None):
|
|
28
|
+
"""A gh-issue dict as fetch_issues returns."""
|
|
29
|
+
d = {"number": num, "title": title, "state": state,
|
|
30
|
+
"assignees": [{"login": l} for l in logins]}
|
|
31
|
+
if milestone:
|
|
32
|
+
d["milestone"] = {"title": milestone}
|
|
33
|
+
return d
|
|
42
34
|
|
|
43
35
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
def _canon_body(ghs, milestone_alignment=None, *, trailing="## Notes\n\nnarrative\n"):
|
|
37
|
+
"""Build a track body whose canonical block is exactly what
|
|
38
|
+
render_canonical_table would emit for `ghs` — so re-derive round-trips."""
|
|
39
|
+
by = {g["number"]: g for g in ghs}
|
|
40
|
+
nums = [g["number"] for g in ghs]
|
|
41
|
+
table = render_canonical_table(nums, by, milestone_alignment)
|
|
42
|
+
return table + "\n---\n\n" + trailing
|
|
47
43
|
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
rc = refresh_md.run(args)
|
|
59
|
-
return rc, mw, buf.getvalue()
|
|
45
|
+
def _track(*, name, repo, issues, body, milestone_alignment=None):
|
|
46
|
+
meta = {"track": name, "status": "active",
|
|
47
|
+
"github": {"repo": repo, "issues": list(issues)}}
|
|
48
|
+
if milestone_alignment:
|
|
49
|
+
meta["milestone_alignment"] = milestone_alignment
|
|
50
|
+
return SimpleNamespace(
|
|
51
|
+
name=name, path=Path(f"/tmp/fake/{name}.md"), body=body, meta=meta,
|
|
52
|
+
has_frontmatter=True, repo=repo,
|
|
53
|
+
)
|
|
60
54
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
|
|
56
|
+
def _drive(track, issues, args):
|
|
57
|
+
cfg = {"notes_root": "/tmp/fake"}
|
|
58
|
+
with patch("commands.refresh_md.load_config", return_value=cfg), \
|
|
59
|
+
patch("commands.refresh_md.discover_tracks", return_value=[track]), \
|
|
60
|
+
patch("commands.refresh_md.fetch_issues", return_value=issues), \
|
|
61
|
+
patch("commands.refresh_md.write_file") as mw:
|
|
62
|
+
buf = io.StringIO()
|
|
63
|
+
with redirect_stdout(buf):
|
|
64
|
+
rc = refresh_md.run(args)
|
|
65
|
+
return rc, mw, buf.getvalue()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CanonicalRederiveTest(unittest.TestCase):
|
|
69
|
+
def test_missing_frontmatter_issues_appear_after_rederive(self):
|
|
70
|
+
"""Issues in frontmatter but absent from the table show up after refresh
|
|
71
|
+
(membership is frontmatter-canonical); table is the new 5-col form."""
|
|
72
|
+
existing = [_gh(1, "first"), _gh(2, "second", "CLOSED")]
|
|
73
|
+
track = _track(name="platform-health", repo="o/r",
|
|
74
|
+
issues=[1, 2, 30, 40], body=_canon_body(existing))
|
|
75
|
+
fetched = existing + [_gh(30, "third", "OPEN", ["bob"]),
|
|
76
|
+
_gh(40, "fourth", "CLOSED")]
|
|
77
|
+
rc, mw, out = _drive(track, fetched, ["platform-health", "--yes"])
|
|
71
78
|
|
|
72
79
|
self.assertEqual(rc, 0)
|
|
73
80
|
mw.assert_called_once()
|
|
74
81
|
new_body = mw.call_args[0][2]
|
|
75
82
|
table = find_canonical_status_tables(new_body)[0]
|
|
76
|
-
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1))
|
|
83
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1))
|
|
84
|
+
for r in table["rows"] if ISSUE_NUM_RE.search(r["cells"][0])]
|
|
77
85
|
self.assertEqual(nums, [1, 2, 30, 40])
|
|
78
|
-
|
|
79
|
-
self.assertIn("| #
|
|
86
|
+
# New 5-column form: # | Title | Milestone | Assignee | Status
|
|
87
|
+
self.assertIn("| # | Title | Milestone | Assignee | Status |", new_body)
|
|
88
|
+
self.assertIn("| #30 | third | | @bob | 🔲 Open |", new_body)
|
|
89
|
+
self.assertIn("| #40 | fourth | | — | ✅ Shipped |", new_body)
|
|
80
90
|
self.assertNotIn("All tracks in sync.", out)
|
|
81
|
-
self.assertIn("row", out.lower())
|
|
82
91
|
|
|
83
92
|
def test_no_drift_reports_in_sync(self):
|
|
93
|
+
"""A canonical block already identical to what render produces → no
|
|
94
|
+
write, 'in sync'. Fixture built from the shared renderer round-trips."""
|
|
95
|
+
ghs = [_gh(1, "first"), _gh(2, "second", "CLOSED")]
|
|
96
|
+
track = _track(name="steady", repo="o/r", issues=[1, 2],
|
|
97
|
+
body=_canon_body(ghs))
|
|
98
|
+
rc, mw, out = _drive(track, ghs, ["steady", "--yes"])
|
|
99
|
+
self.assertEqual(rc, 0)
|
|
100
|
+
mw.assert_not_called()
|
|
101
|
+
self.assertIn("All tracks in sync.", out)
|
|
102
|
+
|
|
103
|
+
def test_status_change_is_rewritten(self):
|
|
104
|
+
"""An issue that closed since last refresh gets its status corrected."""
|
|
105
|
+
ghs_old = [_gh(1, "first", "OPEN")]
|
|
106
|
+
track = _track(name="t", repo="o/r", issues=[1], body=_canon_body(ghs_old))
|
|
107
|
+
rc, mw, out = _drive(track, [_gh(1, "first", "CLOSED")], ["t", "--yes"])
|
|
108
|
+
self.assertEqual(rc, 0)
|
|
109
|
+
mw.assert_called_once()
|
|
110
|
+
self.assertIn("| #1 | first | | — | ✅ Shipped |", mw.call_args[0][2])
|
|
111
|
+
|
|
112
|
+
def test_rederive_orders_active_milestone_first(self):
|
|
113
|
+
"""Re-derive groups + orders issues active-milestone-first, even when
|
|
114
|
+
the existing table was in plain numeric order."""
|
|
115
|
+
# Existing table: numeric order, no milestone awareness.
|
|
116
|
+
stale = [_gh(10, "near"), _gh(20, "far"), _gh(30, "someday")]
|
|
84
117
|
track = _track(
|
|
85
|
-
name="
|
|
86
|
-
|
|
87
|
-
"| #2 | second | — | ✅ Shipped |"],
|
|
118
|
+
name="mixed", repo="o/r", issues=[10, 20, 30],
|
|
119
|
+
body=_canon_body(stale), milestone_alignment="v2.0.0",
|
|
88
120
|
)
|
|
89
|
-
|
|
90
|
-
|
|
121
|
+
# Live data: #20 is the active milestone (v2.0.0), #10 is future, #30 none.
|
|
122
|
+
fetched = [
|
|
123
|
+
_gh(10, "near", milestone="v0.4.0 — MVP"),
|
|
124
|
+
_gh(20, "far", milestone="v2.0.0 — Post-Launch"),
|
|
125
|
+
_gh(30, "someday"),
|
|
126
|
+
]
|
|
127
|
+
rc, mw, out = _drive(track, fetched, ["mixed", "--yes"])
|
|
128
|
+
self.assertEqual(rc, 0)
|
|
129
|
+
mw.assert_called_once()
|
|
130
|
+
new_body = mw.call_args[0][2]
|
|
131
|
+
# Active milestone (v2.0.0 → #20) first, then v0.4.0 (#10), then none (#30).
|
|
132
|
+
self.assertLess(new_body.index("#20"), new_body.index("#10"))
|
|
133
|
+
self.assertLess(new_body.index("#10"), new_body.index("#30"))
|
|
134
|
+
# Milestone column carries the compact label; a blank divider separates groups.
|
|
135
|
+
self.assertIn("| #20 | far | v2.0.0 |", new_body)
|
|
136
|
+
self.assertIn("| #10 | near | v0.4.0 |", new_body)
|
|
137
|
+
self.assertIn("| | | | | |", new_body)
|
|
138
|
+
|
|
139
|
+
def test_dropped_member_is_removed_and_reported(self):
|
|
140
|
+
"""A row in the old table but no longer in frontmatter is dropped on
|
|
141
|
+
re-derive (frontmatter is membership truth) and the removal is reported
|
|
142
|
+
in the pending summary — so a batch approver sees the deletion."""
|
|
143
|
+
existing = [_gh(1, "first"), _gh(2, "second"), _gh(3, "third")]
|
|
144
|
+
track = _track(name="t", repo="o/r", issues=[1, 2], # #3 dropped from frontmatter
|
|
145
|
+
body=_canon_body(existing))
|
|
146
|
+
rc, mw, out = _drive(track, [_gh(1, "first"), _gh(2, "second")],
|
|
147
|
+
["t", "--yes"])
|
|
148
|
+
self.assertEqual(rc, 0)
|
|
149
|
+
mw.assert_called_once()
|
|
150
|
+
new_body = mw.call_args[0][2]
|
|
151
|
+
self.assertNotIn("#3", new_body)
|
|
152
|
+
self.assertIn("1 row(s) removed", out)
|
|
153
|
+
|
|
154
|
+
def test_narrative_block_below_table_is_preserved(self):
|
|
155
|
+
ghs = [_gh(1, "first")]
|
|
156
|
+
track = _track(name="t", repo="o/r", issues=[1, 2],
|
|
157
|
+
body=_canon_body(ghs, trailing="## Notes\n\nkeep me\n"))
|
|
158
|
+
rc, mw, out = _drive(track, [_gh(1, "first"), _gh(2, "second")], ["t", "--yes"])
|
|
159
|
+
self.assertEqual(rc, 0)
|
|
160
|
+
self.assertIn("## Notes\n\nkeep me", mw.call_args[0][2])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class NarrativeTableTest(unittest.TestCase):
|
|
164
|
+
"""Tracks with NO canonical marker keep the conservative in-place behavior."""
|
|
91
165
|
|
|
166
|
+
def _narrative_body(self, rows):
|
|
167
|
+
return (
|
|
168
|
+
"## Issues\n\n"
|
|
169
|
+
"| # | Title | Assignee | Status |\n"
|
|
170
|
+
"|---|---|---|---|\n"
|
|
171
|
+
+ "\n".join(rows) + "\n"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def test_in_place_status_update_keeps_4col_shape(self):
|
|
175
|
+
body = self._narrative_body(["| #1 | first | — | 🔲 Open |"])
|
|
176
|
+
track = _track(name="n", repo="o/r", issues=[1], body=body)
|
|
177
|
+
rc, mw, out = _drive(track, [_gh(1, "first", "CLOSED")], ["n", "--yes"])
|
|
92
178
|
self.assertEqual(rc, 0)
|
|
93
|
-
mw.
|
|
94
|
-
|
|
179
|
+
mw.assert_called_once()
|
|
180
|
+
new_body = mw.call_args[0][2]
|
|
181
|
+
# Narrative tables are NOT migrated to 5 columns or reordered.
|
|
182
|
+
self.assertIn("| #1 | first | — | ✅ Shipped |", new_body)
|
|
183
|
+
self.assertNotIn("Milestone", new_body)
|
|
184
|
+
|
|
185
|
+
def test_missing_row_appended_in_frontmatter_order(self):
|
|
186
|
+
body = self._narrative_body(["| #1 | first | — | 🔲 Open |"])
|
|
187
|
+
track = _track(name="n", repo="o/r", issues=[1, 5], body=body)
|
|
188
|
+
rc, mw, out = _drive(track, [_gh(1, "first"), _gh(5, "fifth", "OPEN", ["x"])],
|
|
189
|
+
["n", "--yes"])
|
|
190
|
+
self.assertEqual(rc, 0)
|
|
191
|
+
mw.assert_called_once()
|
|
192
|
+
self.assertIn("| #5 | fifth | @x | 🔲 Open |", mw.call_args[0][2])
|
|
95
193
|
|
|
96
194
|
|
|
97
195
|
if __name__ == "__main__":
|