@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.
Files changed (42) hide show
  1. package/README.md +13 -7
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/init.py +13 -3
  8. package/skills/work-plan/commands/init_repo.py +8 -2
  9. package/skills/work-plan/commands/new_track.py +7 -0
  10. package/skills/work-plan/commands/notes_vcs.py +172 -0
  11. package/skills/work-plan/commands/refresh_md.py +106 -37
  12. package/skills/work-plan/commands/rename_track.py +243 -0
  13. package/skills/work-plan/commands/set_notes_root.py +8 -4
  14. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  15. package/skills/work-plan/lib/config.py +11 -0
  16. package/skills/work-plan/lib/frontmatter.py +12 -3
  17. package/skills/work-plan/lib/git_state.py +61 -52
  18. package/skills/work-plan/lib/github_state.py +46 -13
  19. package/skills/work-plan/lib/notes_vcs.py +276 -0
  20. package/skills/work-plan/lib/prompts.py +12 -1
  21. package/skills/work-plan/lib/status_table.py +95 -5
  22. package/skills/work-plan/lib/tracks.py +9 -4
  23. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  24. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  25. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  26. package/skills/work-plan/tests/test_config.py +12 -12
  27. package/skills/work-plan/tests/test_github_state.py +3 -3
  28. package/skills/work-plan/tests/test_init_repo.py +12 -7
  29. package/skills/work-plan/tests/test_new_track.py +7 -7
  30. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  31. package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
  32. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  33. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  34. package/skills/work-plan/tests/test_rename_track.py +351 -0
  35. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  36. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  37. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  38. package/skills/work-plan/tests/test_status_table.py +61 -0
  39. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  40. package/skills/work-plan/tests/test_tracks.py +4 -4
  41. package/skills/work-plan/work_plan.py +97 -17
  42. /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=critforge"])
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=critforge"])
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 row-append behavior (issue #77).
2
-
3
- Before #77, refresh-md only rewrote the *status cell* of rows already in the
4
- canonical table it never appended rows for issues present in frontmatter but
5
- missing from the table, yet still printed "All tracks in sync." These tests
6
- pin the membership-aware behavior: missing frontmatter issues get appended in
7
- frontmatter order, and the "in sync" message only prints when nothing changed.
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 MagicMock, patch
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 find_canonical_status_tables, ISSUE_NUM_RE
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 _track(*, name, repo, issues, rows):
32
- body = CANON_HEADER + "\n".join(rows) + "\n\n---\n\n## Notes\n\nnarrative\n"
33
- return SimpleNamespace(
34
- name=name,
35
- path=Path(f"/tmp/fake/{name}.md"),
36
- body=body,
37
- meta={"track": name, "status": "active",
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 _issue(num, title, state="OPEN", logins=()):
45
- return {"number": num, "title": title, "state": state,
46
- "assignees": [{"login": l} for l in logins]}
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
- class RefreshAppendTest(unittest.TestCase):
50
- def _drive(self, track, issues, args):
51
- cfg = {"notes_root": "/tmp/fake"}
52
- with patch("commands.refresh_md.load_config", return_value=cfg), \
53
- patch("commands.refresh_md.discover_tracks", return_value=[track]), \
54
- patch("commands.refresh_md.fetch_issues", return_value=issues), \
55
- patch("commands.refresh_md.write_file") as mw:
56
- buf = io.StringIO()
57
- with redirect_stdout(buf):
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
- def test_appends_missing_rows_in_frontmatter_order(self):
62
- track = _track(
63
- name="platform-health", repo="o/r",
64
- issues=[1, 2, 30, 40], # 30, 40 newly slotted, not yet in table
65
- rows=["| #1 | first | — | 🔲 Open |",
66
- "| #2 | second | — | ✅ Shipped |"],
67
- )
68
- issues = [_issue(1, "first"), _issue(2, "second", "CLOSED"),
69
- _issue(30, "third", "OPEN", ["bob"]), _issue(40, "fourth", "CLOSED")]
70
- rc, mw, out = self._drive(track, issues, ["platform-health", "--yes"])
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)) for r in table["rows"]]
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
- self.assertIn("| #30 | third | @bob | 🔲 Open |", new_body)
79
- self.assertIn("| #40 | fourth | | Shipped |", new_body)
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="steady", repo="o/r", issues=[1, 2],
86
- rows=["| #1 | first | — | 🔲 Open |",
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
- issues = [_issue(1, "first"), _issue(2, "second", "CLOSED")]
90
- rc, mw, out = self._drive(track, issues, ["steady", "--yes"])
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.assert_not_called()
94
- self.assertIn("All tracks in sync.", out)
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__":