@stylusnexus/work-plan 2026.6.14 → 2026.6.15

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.
@@ -0,0 +1,201 @@
1
+ # tests/test_set_next_up.py
2
+ """Tests for the set-next-up command.
3
+
4
+ Mirrors test_set_field.py structure. Tests preset setting, custom order,
5
+ clear, public-repo gating, and validation.
6
+ """
7
+ import io
8
+ import sys
9
+ import unittest
10
+ from contextlib import redirect_stdout, redirect_stderr
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+ from unittest.mock import patch
14
+
15
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(SKILL_ROOT))
17
+
18
+ from commands import set_next_up
19
+ from lib.write_guard import make_token
20
+
21
+
22
+ def _t(name="ph", repo="o/r", meta=None):
23
+ base_meta = {"status": "active", "github": {"repo": repo}}
24
+ if meta is not None:
25
+ base_meta.update(meta)
26
+ return SimpleNamespace(
27
+ name=name,
28
+ repo=repo,
29
+ path=Path(f"/tmp/{name}.md"),
30
+ has_frontmatter=True,
31
+ meta=base_meta,
32
+ body="# b",
33
+ )
34
+
35
+
36
+ def _drive(args, vis="PRIVATE", cfg=None, track=None):
37
+ base_cfg = {"notes_root": "/tmp"}
38
+ if cfg is not None:
39
+ base_cfg.update(cfg)
40
+ t = track if track is not None else _t()
41
+ with patch("commands.set_next_up.load_config", return_value=base_cfg), \
42
+ patch("commands.set_next_up.discover_tracks", return_value=[t]), \
43
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
44
+ patch("commands.set_next_up.write_file") as mw:
45
+ buf = io.StringIO()
46
+ with redirect_stdout(buf):
47
+ rc = set_next_up.run(args)
48
+ return rc, mw, buf.getvalue()
49
+
50
+
51
+ class SetNextUpTest(unittest.TestCase):
52
+
53
+ def test_set_preset_private(self):
54
+ """set-next-up ph --preset=priority-driven on private repo writes next_up_order."""
55
+ rc, mw, out = _drive(["ph", "--preset=priority-driven"])
56
+ self.assertEqual(rc, 0)
57
+ mw.assert_called_once()
58
+ meta = mw.call_args[0][1]
59
+ self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
60
+
61
+ def test_set_order_custom(self):
62
+ """set-next-up ph --order=priority,recency writes next_up_order with preset=custom."""
63
+ rc, mw, out = _drive(["ph", "--order=priority,recency"])
64
+ self.assertEqual(rc, 0)
65
+ mw.assert_called_once()
66
+ meta = mw.call_args[0][1]
67
+ self.assertEqual(meta["next_up_order"], {"preset": "custom", "order": ["priority", "recency"]})
68
+
69
+ def test_clear_removes_key(self):
70
+ """set-next-up ph --clear with next_up_order in meta removes the key."""
71
+ t = _t(meta={"status": "active", "next_up_order": {"preset": "backlog"}})
72
+ rc, mw, out = _drive(["ph", "--clear"], track=t)
73
+ self.assertEqual(rc, 0)
74
+ mw.assert_called_once()
75
+ meta = mw.call_args[0][1]
76
+ self.assertNotIn("next_up_order", meta)
77
+
78
+ def test_public_blocks_without_confirm(self):
79
+ """PUBLIC repo without --confirm emits needs_confirm and does not write."""
80
+ rc, mw, out = _drive(["ph", "--preset=priority-driven"], vis="PUBLIC")
81
+ self.assertEqual(rc, 0)
82
+ mw.assert_not_called()
83
+ self.assertIn("needs_confirm", out)
84
+
85
+ def test_public_with_valid_confirm_writes(self):
86
+ """PUBLIC repo with valid --confirm token proceeds to write."""
87
+ tok = make_token("o/r", "ph")
88
+ rc, mw, out = _drive(["ph", "--preset=priority-driven", f"--confirm={tok}"], vis="PUBLIC")
89
+ self.assertEqual(rc, 0)
90
+ mw.assert_called_once()
91
+ meta = mw.call_args[0][1]
92
+ self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
93
+
94
+ def test_rejects_invalid_preset(self):
95
+ """Unknown preset name → rc=2, no write."""
96
+ rc, mw, out = _drive(["ph", "--preset=nonexistent"])
97
+ self.assertEqual(rc, 2)
98
+ mw.assert_not_called()
99
+
100
+ def test_rejects_invalid_criteria(self):
101
+ """--order with an invalid criterion (bogus) → rc=2, no write."""
102
+ rc, mw, out = _drive(["ph", "--order=bogus,milestone"])
103
+ self.assertEqual(rc, 2)
104
+ mw.assert_not_called()
105
+
106
+ def test_custom_preset_requires_order(self):
107
+ """--preset=custom without --order → rc=2, no write."""
108
+ rc, mw, out = _drive(["ph", "--preset=custom"])
109
+ self.assertEqual(rc, 2)
110
+ mw.assert_not_called()
111
+
112
+ def test_requires_preset_or_order_or_clear(self):
113
+ """No flags at all → rc=2."""
114
+ rc, mw, out = _drive(["ph"])
115
+ self.assertEqual(rc, 2)
116
+ mw.assert_not_called()
117
+
118
+ def test_set_preset_flow(self):
119
+ """--preset=flow is valid and writes correctly."""
120
+ rc, mw, out = _drive(["ph", "--preset=flow"])
121
+ self.assertEqual(rc, 0)
122
+ mw.assert_called_once()
123
+ meta = mw.call_args[0][1]
124
+ self.assertEqual(meta["next_up_order"], {"preset": "flow"})
125
+
126
+ def test_set_preset_backlog(self):
127
+ """--preset=backlog is valid and writes correctly."""
128
+ rc, mw, out = _drive(["ph", "--preset=backlog"])
129
+ self.assertEqual(rc, 0)
130
+ mw.assert_called_once()
131
+ meta = mw.call_args[0][1]
132
+ self.assertEqual(meta["next_up_order"], {"preset": "backlog"})
133
+
134
+ def test_custom_with_order_all_criteria(self):
135
+ """--preset=custom --order=milestone,dependency,priority,recency,aging is valid."""
136
+ rc, mw, out = _drive(["ph", "--preset=custom",
137
+ "--order=milestone,dependency,priority,recency,aging"])
138
+ self.assertEqual(rc, 0)
139
+ mw.assert_called_once()
140
+ meta = mw.call_args[0][1]
141
+ self.assertEqual(meta["next_up_order"]["preset"], "custom")
142
+ self.assertEqual(meta["next_up_order"]["order"],
143
+ ["milestone", "dependency", "priority", "recency", "aging"])
144
+
145
+ def test_order_without_preset_sets_custom(self):
146
+ """--order alone (no --preset) → preset=custom is implied."""
147
+ rc, mw, out = _drive(["ph", "--order=aging,priority"])
148
+ self.assertEqual(rc, 0)
149
+ mw.assert_called_once()
150
+ meta = mw.call_args[0][1]
151
+ self.assertEqual(meta["next_up_order"]["preset"], "custom")
152
+
153
+ def test_clear_on_track_without_key_still_succeeds(self):
154
+ """--clear on a track that has no next_up_order key still writes ok."""
155
+ # meta has no next_up_order key
156
+ t = _t()
157
+ rc, mw, out = _drive(["ph", "--clear"], track=t)
158
+ self.assertEqual(rc, 0)
159
+ mw.assert_called_once()
160
+ meta = mw.call_args[0][1]
161
+ self.assertNotIn("next_up_order", meta)
162
+
163
+ def test_track_not_found_returns_1(self):
164
+ """Unrecognized track name → rc=1."""
165
+ rc, mw, out = _drive(["unknown-track", "--preset=flow"])
166
+ self.assertEqual(rc, 1)
167
+ mw.assert_not_called()
168
+
169
+ def test_does_not_touch_next_up_issue_list(self):
170
+ """set-next-up must not modify the next_up issue-list key."""
171
+ t = _t(meta={"status": "active", "next_up": [101, 102]})
172
+ rc, mw, out = _drive(["ph", "--preset=flow"], track=t)
173
+ self.assertEqual(rc, 0)
174
+ mw.assert_called_once()
175
+ meta = mw.call_args[0][1]
176
+ # next_up issue list must be unchanged
177
+ self.assertEqual(meta.get("next_up"), [101, 102])
178
+
179
+ def test_named_preset_plus_order_warns_and_ignores_order(self):
180
+ """--preset=<named> + --order: WARN on stderr; named preset wins, order dropped."""
181
+ base_cfg = {"notes_root": "/tmp"}
182
+ t = _t()
183
+ with patch("commands.set_next_up.load_config", return_value=base_cfg), \
184
+ patch("commands.set_next_up.discover_tracks", return_value=[t]), \
185
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
186
+ patch("commands.set_next_up.write_file") as mw:
187
+ out, err = io.StringIO(), io.StringIO()
188
+ with redirect_stdout(out), redirect_stderr(err):
189
+ rc = set_next_up.run(["ph", "--preset=priority-driven",
190
+ "--order=aging,priority"])
191
+ self.assertEqual(rc, 0)
192
+ mw.assert_called_once()
193
+ meta = mw.call_args[0][1]
194
+ # Named preset wins — the co-supplied order is NOT stored.
195
+ self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
196
+ self.assertIn("WARN", err.getvalue())
197
+ self.assertIn("--order is ignored", err.getvalue())
198
+
199
+
200
+ if __name__ == "__main__":
201
+ unittest.main()
@@ -60,6 +60,7 @@ SUBCOMMANDS = {
60
60
  "auth-status": "commands.auth_status",
61
61
  "list-open-issues": "commands.list_open_issues",
62
62
  "set": "commands.set_field",
63
+ "set-next-up": "commands.set_next_up",
63
64
  "new-track": "commands.new_track",
64
65
  "rename-track": "commands.rename_track",
65
66
  "set-notes-root": "commands.set_notes_root",
@@ -166,6 +167,10 @@ DESCRIPTIONS = [
166
167
  "Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
167
168
  "Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
168
169
  "/work-plan set ux-redesign status=parked"),
170
+ ("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear) [--repo=<key>] [--confirm=<token>]",
171
+ "Configure the ranking preset for a track's auto next_up suggestion. --preset sets one of the named presets (flow, priority-driven, backlog) or 'custom' (which requires --order). --order=a,b,c sets a custom comma-separated criterion list (milestone, dependency, priority, recency, aging). --clear reverts to the global or default preset. Writes next_up_order into the track's frontmatter (does NOT touch the next_up issue list). Public-repo gated: without --confirm it prints {needs_confirm, reason, token} and makes no change.",
172
+ "When you want a track to use a different ranking order than the default (flow). Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
173
+ "/work-plan set-next-up my-track --preset=priority-driven"),
169
174
  ("new-track", "<repo> <slug> [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]",
170
175
  "Create a brand-new track file under notes_root in one headless call. <repo> is either a configured key (e.g. 'myproject') or a bare org/repo slug (e.g. 'your-org/myproject'). Writes frontmatter with status=active and optional priority/milestone. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token> to proceed.",
171
176
  "When a new feature branch or initiative starts and you want the track file created immediately — especially from a non-terminal caller like the VS Code extension that can't interactively run init.",