@stylusnexus/work-plan 2026.6.9 → 2026.6.10

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 (53) hide show
  1. package/README.md +91 -13
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +71 -17
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +17 -5
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +39 -7
  17. package/skills/work-plan/commands/init_repo.py +43 -1
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +100 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +19 -6
  23. package/skills/work-plan/commands/set_field.py +17 -7
  24. package/skills/work-plan/commands/slot.py +20 -5
  25. package/skills/work-plan/commands/where_was_i.py +23 -5
  26. package/skills/work-plan/lib/config.py +6 -0
  27. package/skills/work-plan/lib/export_model.py +57 -2
  28. package/skills/work-plan/lib/github_state.py +54 -13
  29. package/skills/work-plan/lib/notes_readme.py +38 -0
  30. package/skills/work-plan/lib/prompts.py +34 -3
  31. package/skills/work-plan/lib/tracks.py +208 -18
  32. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  33. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  34. package/skills/work-plan/tests/test_close_tier.py +166 -0
  35. package/skills/work-plan/tests/test_config_shared.py +57 -0
  36. package/skills/work-plan/tests/test_coverage.py +192 -0
  37. package/skills/work-plan/tests/test_export.py +204 -1
  38. package/skills/work-plan/tests/test_export_command.py +2 -2
  39. package/skills/work-plan/tests/test_github_state.py +52 -14
  40. package/skills/work-plan/tests/test_group_apply.py +411 -0
  41. package/skills/work-plan/tests/test_init_repo.py +128 -0
  42. package/skills/work-plan/tests/test_init_shared.py +185 -0
  43. package/skills/work-plan/tests/test_list_sort.py +162 -0
  44. package/skills/work-plan/tests/test_move.py +240 -0
  45. package/skills/work-plan/tests/test_new_track.py +169 -4
  46. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  47. package/skills/work-plan/tests/test_prompts.py +121 -0
  48. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  49. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  50. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  51. package/skills/work-plan/tests/test_tracks.py +395 -1
  52. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  53. package/skills/work-plan/work_plan.py +38 -18
@@ -0,0 +1,185 @@
1
+ """Tests for init command on shared (.work-plan/) paths — Phase C."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from unittest.mock import patch, MagicMock
8
+
9
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SKILL_ROOT))
11
+
12
+ from commands import init
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
20
+ if repos is None:
21
+ repos = {
22
+ "myrepo": {
23
+ "github": "org/myrepo",
24
+ "local": "/home/user/projects/myrepo",
25
+ }
26
+ }
27
+ return {"notes_root": notes_root, "repos": repos}
28
+
29
+
30
+ def _drive_shared(args, *, cfg=None, body="", vis="PRIVATE",
31
+ path_str=None, meta=None):
32
+ """Run init.run on a path inside a .work-plan/ directory.
33
+
34
+ Uses paths that are already absolute and canonical (no symlink resolution
35
+ needed) and patches expanduser/resolve so they return the path unchanged.
36
+ Config local paths match the fake clone root exactly.
37
+ """
38
+ if cfg is None:
39
+ cfg = _make_cfg()
40
+ if path_str is None:
41
+ path_str = "/home/user/projects/myrepo/.work-plan/my-track.md"
42
+ fake_path = Path(path_str)
43
+ existing_meta = meta if meta is not None else {}
44
+
45
+ # Patch expanduser to be a no-op and resolve to return self, so Path
46
+ # comparisons inside _find_repo_for_shared_path use the literal strings
47
+ # we put in cfg["repos"][...]["local"].
48
+ _orig_expanduser = Path.expanduser
49
+ _orig_resolve = Path.resolve
50
+
51
+ def _noop_expanduser(self):
52
+ return self
53
+
54
+ def _noop_resolve(self):
55
+ return self
56
+
57
+ with patch("commands.init.load_config", return_value=cfg), \
58
+ patch("commands.init.parse_file", return_value=(existing_meta, body)), \
59
+ patch("commands.init.write_file") as mw, \
60
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
61
+ patch("pathlib.Path.exists", return_value=True), \
62
+ patch("pathlib.Path.expanduser", _noop_expanduser), \
63
+ patch("pathlib.Path.resolve", _noop_resolve):
64
+ full_args = [str(fake_path)] + list(args)
65
+ buf = io.StringIO()
66
+ with redirect_stdout(buf):
67
+ rc = init.run(full_args)
68
+ return rc, mw, buf.getvalue()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Tests
73
+ # ---------------------------------------------------------------------------
74
+
75
+ class InitSharedPathTest(unittest.TestCase):
76
+
77
+ def test_shared_path_resolves_repo_from_config(self):
78
+ """init on a .work-plan/ path resolves github repo from config entry."""
79
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
80
+ self.assertEqual(rc, 0)
81
+ mw.assert_called_once()
82
+ written_meta = mw.call_args[0][1]
83
+ self.assertEqual(written_meta["github"]["repo"], "org/myrepo")
84
+
85
+ def test_shared_path_never_writes_tbd(self):
86
+ """init on a .work-plan/ path never writes github.repo == 'TBD'."""
87
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
88
+ self.assertEqual(rc, 0)
89
+ written_meta = mw.call_args[0][1]
90
+ self.assertNotEqual(written_meta["github"]["repo"], "TBD")
91
+
92
+ def test_shared_path_prints_tier_shared(self):
93
+ """init on a .work-plan/ path prints 'tier: shared'."""
94
+ rc, mw, out = _drive_shared([], vis="PRIVATE")
95
+ self.assertEqual(rc, 0)
96
+ self.assertIn("tier: shared", out)
97
+
98
+ def test_shared_path_unregistered_repo_returns_rc1(self):
99
+ """init on a .work-plan/ dir not in config → error, rc 1."""
100
+ # Config has no matching local path
101
+ cfg = _make_cfg(repos={
102
+ "other": {
103
+ "github": "org/other",
104
+ "local": "/home/user/projects/other",
105
+ }
106
+ })
107
+ rc, mw, out = _drive_shared([], cfg=cfg, vis="PRIVATE")
108
+ self.assertEqual(rc, 1)
109
+ mw.assert_not_called()
110
+ self.assertIn("ERROR", out)
111
+ self.assertIn("init-repo", out)
112
+
113
+ def test_shared_path_already_has_frontmatter_no_write(self):
114
+ """init on a .work-plan/ path that already has frontmatter → no write, rc 0."""
115
+ existing = {"track": "my-track", "status": "active"}
116
+ rc, mw, out = _drive_shared([], meta=existing, vis="PRIVATE")
117
+ self.assertEqual(rc, 0)
118
+ mw.assert_not_called()
119
+ self.assertIn("already has frontmatter", out)
120
+
121
+ def test_shared_path_body_issue_refs_captured(self):
122
+ """init on a .work-plan/ path scans body for issue refs."""
123
+ body = "Implements #42 and #99.\n"
124
+ rc, mw, out = _drive_shared([], body=body, vis="PRIVATE")
125
+ self.assertEqual(rc, 0)
126
+ written_meta = mw.call_args[0][1]
127
+ self.assertEqual(written_meta["github"]["issues"], [42, 99])
128
+
129
+ def test_shared_path_public_repo_requires_confirm(self):
130
+ """init on a .work-plan/ path with PUBLIC repo → needs_confirm JSON, no write."""
131
+ rc, mw, out = _drive_shared([], vis="PUBLIC")
132
+ self.assertEqual(rc, 0)
133
+ mw.assert_not_called()
134
+ import json
135
+ data = json.loads(out.strip())
136
+ self.assertTrue(data["needs_confirm"])
137
+
138
+
139
+ def _noop_expanduser(self):
140
+ return self
141
+
142
+
143
+ def _noop_resolve(self):
144
+ return self
145
+
146
+
147
+ class InitFindRepoHelperTest(unittest.TestCase):
148
+ """Unit tests for _find_repo_for_shared_path.
149
+
150
+ Use expanduser/resolve no-op patches so that literal path strings in cfg
151
+ compare equal to the Path objects derived from the track path.
152
+ """
153
+
154
+ def test_finds_registered_repo(self):
155
+ """_find_repo_for_shared_path returns github slug for registered clone."""
156
+ cfg = _make_cfg()
157
+ path = Path("/home/user/projects/myrepo/.work-plan/some-track.md")
158
+ with patch("pathlib.Path.expanduser", _noop_expanduser), \
159
+ patch("pathlib.Path.resolve", _noop_resolve):
160
+ result = init._find_repo_for_shared_path(path, cfg)
161
+ self.assertEqual(result, "org/myrepo")
162
+
163
+ def test_returns_none_for_unregistered_clone(self):
164
+ """_find_repo_for_shared_path returns None when clone not in config."""
165
+ cfg = _make_cfg(repos={
166
+ "other": {"github": "org/other", "local": "/home/user/projects/other"},
167
+ })
168
+ path = Path("/home/user/projects/myrepo/.work-plan/some-track.md")
169
+ with patch("pathlib.Path.expanduser", _noop_expanduser), \
170
+ patch("pathlib.Path.resolve", _noop_resolve):
171
+ result = init._find_repo_for_shared_path(path, cfg)
172
+ self.assertIsNone(result)
173
+
174
+ def test_returns_none_for_non_shared_path(self):
175
+ """_find_repo_for_shared_path returns None for a path not in .work-plan/."""
176
+ cfg = _make_cfg()
177
+ path = Path("/tmp/fake-notes/myrepo/some-track.md")
178
+ # No patches needed — the path doesn't have .work-plan in parts,
179
+ # so the function returns None before calling expanduser/resolve.
180
+ result = init._find_repo_for_shared_path(path, cfg)
181
+ self.assertIsNone(result)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ unittest.main()
@@ -0,0 +1,162 @@
1
+ """Tests for the `list --sort` flag (issue #181).
2
+
3
+ Covers:
4
+ - --sort=recent orders active tracks by last_touched descending.
5
+ - Tracks missing last_touched sort LAST under --sort=recent.
6
+ - --sort=priority orders P0→P3 with last_touched recency as tiebreaker;
7
+ tracks missing launch_priority sort after those that have it.
8
+ - Default (no --sort) preserves discovery (filesystem) order exactly.
9
+ - --all still appends the archived section, and works alongside --sort.
10
+ - An invalid --sort value (or bare --sort) returns rc 2.
11
+ """
12
+ import io
13
+ import sys
14
+ import unittest
15
+ from contextlib import redirect_stdout
16
+ from pathlib import Path
17
+ from types import SimpleNamespace
18
+ from unittest.mock import patch
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(SKILL_ROOT))
22
+
23
+ from commands import list_cmd
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _track(name, *, repo="ok/repo", status="active", priority=None, last_touched=None):
31
+ meta = {"track": name, "status": status}
32
+ if priority is not None:
33
+ meta["launch_priority"] = priority
34
+ if last_touched is not None:
35
+ meta["last_touched"] = last_touched
36
+ return SimpleNamespace(
37
+ name=name,
38
+ path=Path(f"/tmp/fake/{name}.md"),
39
+ body="# fake",
40
+ meta=meta,
41
+ has_frontmatter=True,
42
+ needs_init=False,
43
+ needs_filing=False,
44
+ repo=repo,
45
+ )
46
+
47
+
48
+ def _drive(args, tracks, archived=None):
49
+ """Run list_cmd.run(args) with config + discovery mocked. Returns (rc, output)."""
50
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {}}
51
+ with patch("commands.list_cmd.load_config", return_value=cfg), \
52
+ patch("commands.list_cmd.discover_tracks", return_value=tracks), \
53
+ patch("commands.list_cmd.discover_archived_tracks", return_value=archived or []):
54
+ buf = io.StringIO()
55
+ with redirect_stdout(buf):
56
+ rc = list_cmd.run(args)
57
+ return rc, buf.getvalue()
58
+
59
+
60
+ def _order(output, names):
61
+ """Return the names from `names` in the order they appear in output."""
62
+ positions = [(output.index(n), n) for n in names if n in output]
63
+ return [n for _, n in sorted(positions)]
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Test cases
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class ListSortTest(unittest.TestCase):
71
+
72
+ def test_sort_recent_orders_by_last_touched_desc(self):
73
+ """--sort=recent orders tracks most-recently-touched first."""
74
+ tracks = [
75
+ _track("old", last_touched="2026-01-01"),
76
+ _track("newest", last_touched="2026-06-01"),
77
+ _track("middle", last_touched="2026-03-15"),
78
+ ]
79
+ rc, out = _drive(["--sort=recent"], tracks)
80
+ self.assertEqual(rc, 0)
81
+ self.assertEqual(_order(out, ["old", "newest", "middle"]),
82
+ ["newest", "middle", "old"])
83
+
84
+ def test_sort_recent_missing_last_touched_sorts_last(self):
85
+ """Tracks with no last_touched sort after those that have one."""
86
+ tracks = [
87
+ _track("nodate"),
88
+ _track("dated", last_touched="2026-05-01"),
89
+ ]
90
+ rc, out = _drive(["--sort=recent"], tracks)
91
+ self.assertEqual(rc, 0)
92
+ self.assertEqual(_order(out, ["nodate", "dated"]), ["dated", "nodate"])
93
+
94
+ def test_sort_priority_orders_p0_to_p3_with_recency_tiebreak(self):
95
+ """--sort=priority orders P0→P3; equal priority breaks by recency."""
96
+ tracks = [
97
+ _track("p2", priority="P2", last_touched="2026-01-01"),
98
+ _track("p0", priority="P0", last_touched="2026-01-01"),
99
+ _track("p1_old", priority="P1", last_touched="2026-01-01"),
100
+ _track("p1_new", priority="P1", last_touched="2026-06-01"),
101
+ ]
102
+ rc, out = _drive(["--sort=priority"], tracks)
103
+ self.assertEqual(rc, 0)
104
+ # P0 first, then P1 (newest of the two first), then P2
105
+ self.assertEqual(
106
+ _order(out, ["p0", "p1_new", "p1_old", "p2"]),
107
+ ["p0", "p1_new", "p1_old", "p2"],
108
+ )
109
+
110
+ def test_sort_priority_missing_priority_sorts_after_known(self):
111
+ """Tracks missing launch_priority sort after those that have it."""
112
+ tracks = [
113
+ _track("none"),
114
+ _track("p3", priority="P3"),
115
+ _track("p0", priority="P0"),
116
+ ]
117
+ rc, out = _drive(["--sort=priority"], tracks)
118
+ self.assertEqual(rc, 0)
119
+ self.assertEqual(_order(out, ["none", "p3", "p0"]), ["p0", "p3", "none"])
120
+
121
+ def test_default_preserves_discovery_order(self):
122
+ """No --sort flag preserves the exact filesystem discovery order."""
123
+ tracks = [
124
+ _track("zebra", priority="P3", last_touched="2026-01-01"),
125
+ _track("alpha", priority="P0", last_touched="2026-06-01"),
126
+ _track("mango", priority="P1", last_touched="2026-03-01"),
127
+ ]
128
+ rc, out = _drive([], tracks)
129
+ self.assertEqual(rc, 0)
130
+ # Discovery order is preserved despite priority/recency differences.
131
+ self.assertEqual(_order(out, ["zebra", "alpha", "mango"]),
132
+ ["zebra", "alpha", "mango"])
133
+
134
+ def test_all_appends_archived_section_with_sort(self):
135
+ """--all still appends the Archived section alongside --sort."""
136
+ tracks = [
137
+ _track("recent_active", last_touched="2026-06-01"),
138
+ _track("old_active", last_touched="2026-01-01"),
139
+ ]
140
+ archived = [_track("done_track", status="shipped")]
141
+ rc, out = _drive(["--all", "--sort=recent"], tracks, archived=archived)
142
+ self.assertEqual(rc, 0)
143
+ self.assertIn("Archived:", out)
144
+ self.assertIn("done_track", out)
145
+ # Active section still recency-sorted, and archived comes after.
146
+ self.assertLess(out.index("recent_active"), out.index("old_active"))
147
+ self.assertLess(out.index("old_active"), out.index("Archived:"))
148
+
149
+ def test_invalid_sort_value_returns_rc2(self):
150
+ """An unrecognized --sort value returns rc 2 (usage error)."""
151
+ rc, out = _drive(["--sort=bogus"], [_track("a")])
152
+ self.assertEqual(rc, 2)
153
+ self.assertIn("usage", out)
154
+
155
+ def test_bare_sort_flag_returns_rc2(self):
156
+ """--sort with no value returns rc 2."""
157
+ rc, out = _drive(["--sort"], [_track("a")])
158
+ self.assertEqual(rc, 2)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ unittest.main()
@@ -0,0 +1,240 @@
1
+ """Tests for the move subcommand (issue #162).
2
+
3
+ Covers:
4
+ - Moves an issue from source track to destination track (two writes).
5
+ - Issue not in source → error, rc 1.
6
+ - Cross-repo guard → error, rc 1.
7
+ - Same-track no-op → rc 0, message.
8
+ - Already in destination → remove from source only, rc 0.
9
+ - Public repo confirm gate → prints needs_confirm JSON, rc 0.
10
+ - Public repo with valid --confirm=<token> → writes, rc 0.
11
+ - Bad args → rc 2.
12
+ """
13
+ import io
14
+ import sys
15
+ import unittest
16
+ from contextlib import redirect_stdout
17
+ from pathlib import Path
18
+ from types import SimpleNamespace
19
+ from unittest.mock import MagicMock, patch
20
+
21
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
22
+ sys.path.insert(0, str(SKILL_ROOT))
23
+
24
+ from commands import move
25
+ from lib.write_guard import make_token
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _track(*, name, repo="ok/repo", issues=None, status="active"):
33
+ return SimpleNamespace(
34
+ name=name,
35
+ path=Path(f"/tmp/fake/{name}.md"),
36
+ body="# fake",
37
+ meta={
38
+ "track": name,
39
+ "status": status,
40
+ "github": {"repo": repo, "issues": list(issues or [])},
41
+ },
42
+ has_frontmatter=True,
43
+ repo=repo,
44
+ )
45
+
46
+
47
+ def _drive(args, tracks=None, vis="PRIVATE"):
48
+ """Run move.run(args) with all external I/O mocked."""
49
+ if tracks is None:
50
+ tracks = [
51
+ _track(name="alpha", issues=[42]),
52
+ _track(name="beta", issues=[]),
53
+ ]
54
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
55
+
56
+ with patch("commands.move.load_config", return_value=cfg), \
57
+ patch("commands.move.discover_tracks", return_value=tracks), \
58
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
59
+ patch("commands.move.write_file") as mw:
60
+ buf = io.StringIO()
61
+ with redirect_stdout(buf):
62
+ rc = move.run(args)
63
+ return rc, mw, buf.getvalue()
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Test cases
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class MoveBasicTest(unittest.TestCase):
71
+
72
+ def test_moves_issue_from_source_to_destination(self):
73
+ """Move #42 from alpha to beta: both tracks written."""
74
+ tracks = [
75
+ _track(name="alpha", issues=[42, 99]),
76
+ _track(name="beta", issues=[7]),
77
+ ]
78
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
79
+ self.assertEqual(rc, 0)
80
+ self.assertIn("Removed #42 from 'alpha'", out)
81
+ self.assertIn("Added #42 to 'beta'", out)
82
+ # Two writes: source then destination
83
+ self.assertEqual(mw.call_count, 2)
84
+
85
+ # Source: 42 removed, 99 remains
86
+ source_call = mw.call_args_list[0]
87
+ source_issues = source_call[0][1]["github"]["issues"]
88
+ self.assertNotIn(42, source_issues)
89
+ self.assertIn(99, source_issues)
90
+
91
+ # Destination: 42 added, 7 remains, sorted
92
+ dest_call = mw.call_args_list[1]
93
+ dest_issues = dest_call[0][1]["github"]["issues"]
94
+ self.assertIn(42, dest_issues)
95
+ self.assertIn(7, dest_issues)
96
+ self.assertEqual(dest_issues, sorted(dest_issues))
97
+
98
+ def test_issue_not_in_source_errors(self):
99
+ """#999 is not in alpha → rc 1, error message."""
100
+ tracks = [
101
+ _track(name="alpha", issues=[42]),
102
+ _track(name="beta", issues=[]),
103
+ ]
104
+ rc, mw, out = _drive(["999", "alpha", "beta"], tracks=tracks)
105
+ self.assertEqual(rc, 1)
106
+ self.assertIn("not in track", out)
107
+ mw.assert_not_called()
108
+
109
+ def test_cross_repo_move_errors(self):
110
+ """Moving between different repos is rejected."""
111
+ tracks = [
112
+ _track(name="alpha", repo="ok/repo", issues=[42]),
113
+ _track(name="beta", repo="other/repo", issues=[]),
114
+ ]
115
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
116
+ self.assertEqual(rc, 1)
117
+ self.assertIn("cross-repo", out)
118
+ mw.assert_not_called()
119
+
120
+ def test_same_track_noop(self):
121
+ """Moving to the same track is a no-op."""
122
+ tracks = [
123
+ _track(name="alpha", issues=[42]),
124
+ _track(name="beta", issues=[]),
125
+ ]
126
+ rc, mw, out = _drive(["42", "alpha", "alpha"], tracks=tracks)
127
+ self.assertEqual(rc, 0)
128
+ self.assertIn("already in track", out)
129
+ mw.assert_not_called()
130
+
131
+ def test_already_in_destination_removes_from_source_only(self):
132
+ """#42 already in beta → remove from alpha, don't re-add to beta."""
133
+ tracks = [
134
+ _track(name="alpha", issues=[42, 99]),
135
+ _track(name="beta", issues=[42, 7]),
136
+ ]
137
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
138
+ self.assertEqual(rc, 0)
139
+ self.assertIn("already in track 'beta'", out)
140
+ self.assertIn("Removed #42 from 'alpha'", out)
141
+ # Only one write (source only, dest unchanged)
142
+ self.assertEqual(mw.call_count, 1)
143
+
144
+ call = mw.call_args_list[0]
145
+ source_issues = call[0][1]["github"]["issues"]
146
+ self.assertNotIn(42, source_issues)
147
+ self.assertIn(99, source_issues)
148
+
149
+ def test_bad_args_usage(self):
150
+ """Less than 3 positional args → rc 2."""
151
+ rc, mw, out = _drive(["42", "alpha"])
152
+ self.assertEqual(rc, 2)
153
+ self.assertIn("usage:", out)
154
+ mw.assert_not_called()
155
+
156
+ def test_non_numeric_issue_errors(self):
157
+ """Non-numeric issue number → rc 2."""
158
+ rc, mw, out = _drive(["abc", "alpha", "beta"])
159
+ self.assertEqual(rc, 2)
160
+ self.assertIn("not an issue number", out)
161
+ mw.assert_not_called()
162
+
163
+
164
+ class MovePublicRepoTest(unittest.TestCase):
165
+
166
+ def test_public_repo_prints_needs_confirm(self):
167
+ """Public repo without --confirm prints needs_confirm JSON."""
168
+ tracks = [
169
+ _track(name="alpha", repo="ok/repo", issues=[42]),
170
+ _track(name="beta", repo="ok/repo", issues=[]),
171
+ ]
172
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks, vis="PUBLIC")
173
+ self.assertEqual(rc, 0)
174
+ self.assertIn('"needs_confirm": true', out)
175
+ self.assertIn('"token":', out)
176
+ mw.assert_not_called()
177
+
178
+ def test_public_repo_with_valid_confirm_writes(self):
179
+ """Public repo with valid --confirm=<token> writes successfully."""
180
+ tracks = [
181
+ _track(name="alpha", repo="ok/repo", issues=[42]),
182
+ _track(name="beta", repo="ok/repo", issues=[]),
183
+ ]
184
+ token = make_token("ok/repo", "beta")
185
+ rc, mw, out = _drive(
186
+ ["42", "alpha", "beta", f"--confirm={token}"],
187
+ tracks=tracks,
188
+ vis="PUBLIC",
189
+ )
190
+ self.assertEqual(rc, 0)
191
+ self.assertIn("Removed #42 from 'alpha'", out)
192
+ self.assertIn("Added #42 to 'beta'", out)
193
+ self.assertEqual(mw.call_count, 2)
194
+
195
+ def test_private_repo_no_token_writes_directly(self):
196
+ """Private repo writes without any confirm gate."""
197
+ tracks = [
198
+ _track(name="alpha", issues=[42]),
199
+ _track(name="beta", issues=[]),
200
+ ]
201
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
202
+ self.assertEqual(rc, 0)
203
+ self.assertIn("Removed", out)
204
+ self.assertIn("Added", out)
205
+ self.assertEqual(mw.call_count, 2)
206
+
207
+
208
+ class MoveTrackResolutionTest(unittest.TestCase):
209
+
210
+ def test_no_active_track_matching(self):
211
+ """Inactive or nonexistent source track → rc 1."""
212
+ tracks = [
213
+ _track(name="alpha", issues=[42], status="shipped"),
214
+ _track(name="beta", issues=[]),
215
+ ]
216
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
217
+ self.assertEqual(rc, 1)
218
+ self.assertIn("No active track matching", out)
219
+
220
+ def test_no_active_destination(self):
221
+ """Inactive destination track → rc 1."""
222
+ tracks = [
223
+ _track(name="alpha", issues=[42]),
224
+ _track(name="beta", issues=[], status="shipped"),
225
+ ]
226
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
227
+ self.assertEqual(rc, 1)
228
+ self.assertIn("No active track matching", out)
229
+
230
+ def test_issue_already_in_archived_track(self):
231
+ """Moving from an active track even if issue is also in a shipped track."""
232
+ tracks = [
233
+ _track(name="alpha", issues=[42]),
234
+ _track(name="beta", issues=[]),
235
+ ]
236
+ rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
237
+ self.assertEqual(rc, 0)
238
+ self.assertIn("Removed", out)
239
+ self.assertIn("Added", out)
240
+ self.assertEqual(mw.call_count, 2)