@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,166 @@
1
+ """Tests for tier-aware archive display in close command (Phase C)."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import patch
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import close
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _shared_track(*, name="auth-flow", repo="org/myrepo"):
21
+ """Return a SimpleNamespace that looks like a shared Track."""
22
+ return SimpleNamespace(
23
+ name=name,
24
+ # Path is under a .work-plan/ dir, NOT under notes_root
25
+ path=Path(f"/home/user/projects/myrepo/.work-plan/{name}.md"),
26
+ body="# shared track body",
27
+ meta={
28
+ "track": name,
29
+ "status": "active",
30
+ "github": {"repo": repo},
31
+ },
32
+ has_frontmatter=True,
33
+ repo=repo,
34
+ tier="shared",
35
+ )
36
+
37
+
38
+ def _private_track(*, name="alpha", repo="ok/repo"):
39
+ """Return a SimpleNamespace for a private (notes_root) Track."""
40
+ return SimpleNamespace(
41
+ name=name,
42
+ path=Path(f"/tmp/fake-notes/ok/{name}.md"),
43
+ body="# private track body",
44
+ meta={
45
+ "track": name,
46
+ "status": "active",
47
+ "github": {"repo": repo},
48
+ },
49
+ has_frontmatter=True,
50
+ repo=repo,
51
+ tier="private",
52
+ )
53
+
54
+
55
+ def _drive(args, track, notes_root="/tmp/fake-notes", vis="PRIVATE"):
56
+ cfg = {
57
+ "notes_root": notes_root,
58
+ "repos": {"ok": {"github": "ok/repo"}, "myrepo": {"github": "org/myrepo"}},
59
+ }
60
+ with patch("commands.close.load_config", return_value=cfg), \
61
+ patch("commands.close.discover_tracks", return_value=[track]), \
62
+ patch("commands.close.find_track_by_name", return_value=track), \
63
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
64
+ patch("commands.close.write_file") as mw, \
65
+ patch("commands.close.shutil") as ms, \
66
+ patch("pathlib.Path.mkdir"):
67
+ buf = io.StringIO()
68
+ with redirect_stdout(buf):
69
+ rc = close.run(args)
70
+ return rc, mw, ms, buf.getvalue()
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Tests
75
+ # ---------------------------------------------------------------------------
76
+
77
+ class CloseTierTest(unittest.TestCase):
78
+
79
+ def test_shared_track_shipped_does_not_crash_on_relative_to(self):
80
+ """close on a shared track: archive is outside notes_root —
81
+ should NOT raise ValueError, falls back to absolute path display."""
82
+ track = _shared_track()
83
+ rc, mw, ms, out = _drive(
84
+ ["auth-flow", "--state=shipped"],
85
+ track=track,
86
+ notes_root="/tmp/fake-notes",
87
+ vis="PRIVATE",
88
+ )
89
+ self.assertEqual(rc, 0)
90
+ mw.assert_called_once()
91
+ ms.move.assert_called_once()
92
+ # Output should contain the track name and end state
93
+ self.assertIn("auth-flow", out)
94
+ self.assertIn("shipped", out)
95
+
96
+ def test_shared_track_shipped_prints_commit_hint(self):
97
+ """close on a shared track → output includes commit+push hint."""
98
+ track = _shared_track()
99
+ rc, mw, ms, out = _drive(
100
+ ["auth-flow", "--state=shipped"],
101
+ track=track,
102
+ notes_root="/tmp/fake-notes",
103
+ vis="PRIVATE",
104
+ )
105
+ self.assertEqual(rc, 0)
106
+ self.assertIn("shared track", out)
107
+ self.assertIn("commit + push", out)
108
+
109
+ def test_private_track_shipped_no_commit_hint(self):
110
+ """close on a private track → no commit+push hint in output."""
111
+ track = _private_track()
112
+ rc, mw, ms, out = _drive(
113
+ ["alpha", "--state=shipped"],
114
+ track=track,
115
+ notes_root="/tmp/fake-notes",
116
+ vis="PRIVATE",
117
+ )
118
+ self.assertEqual(rc, 0)
119
+ self.assertNotIn("commit + push", out)
120
+
121
+ def test_shared_track_abandoned_prints_commit_hint(self):
122
+ """close --state=abandoned on a shared track → commit+push hint."""
123
+ track = _shared_track(name="old-feature")
124
+ rc, mw, ms, out = _drive(
125
+ ["old-feature", "--state=abandoned"],
126
+ track=track,
127
+ notes_root="/tmp/fake-notes",
128
+ vis="PRIVATE",
129
+ )
130
+ self.assertEqual(rc, 0)
131
+ self.assertIn("shared track", out)
132
+ self.assertIn("commit + push", out)
133
+
134
+ def test_shared_track_parked_no_move_no_hint(self):
135
+ """close --state=parked on a shared track → parked (no move),
136
+ no commit+push hint (parked stays in place, returns early)."""
137
+ track = _shared_track()
138
+ rc, mw, ms, out = _drive(
139
+ ["auth-flow", "--state=parked"],
140
+ track=track,
141
+ notes_root="/tmp/fake-notes",
142
+ vis="PRIVATE",
143
+ )
144
+ self.assertEqual(rc, 0)
145
+ ms.move.assert_not_called()
146
+ # The commit hint is only printed after a move (archive operation)
147
+ self.assertNotIn("commit + push", out)
148
+
149
+ def test_private_track_shipped_display_uses_relative_path(self):
150
+ """Private track close shows path relative to notes_root (existing behaviour)."""
151
+ track = _private_track()
152
+ rc, mw, ms, out = _drive(
153
+ ["alpha", "--state=shipped"],
154
+ track=track,
155
+ notes_root="/tmp/fake-notes",
156
+ vis="PRIVATE",
157
+ )
158
+ self.assertEqual(rc, 0)
159
+ # Should not contain the absolute prefix for private tracks
160
+ # (it will contain 'ok/archive/shipped/alpha.md' or similar)
161
+ self.assertIn("shipped", out)
162
+ self.assertIn("alpha", out)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ """Tests for is_valid_git_repo() in lib/config."""
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.config import is_valid_git_repo
11
+
12
+
13
+ class IsValidGitRepoTest(unittest.TestCase):
14
+ def test_returns_true_for_dir_with_dot_git(self):
15
+ with tempfile.TemporaryDirectory() as d:
16
+ base = Path(d)
17
+ (base / ".git").mkdir()
18
+ self.assertTrue(is_valid_git_repo(base))
19
+
20
+ def test_dot_git_can_be_a_file_worktree(self):
21
+ """Worktrees have .git as a file, not a dir — still truthy via .exists()."""
22
+ with tempfile.TemporaryDirectory() as d:
23
+ base = Path(d)
24
+ (base / ".git").write_text("gitdir: ../.git/worktrees/foo\n", encoding="utf-8")
25
+ self.assertTrue(is_valid_git_repo(base))
26
+
27
+ def test_returns_false_for_nonexistent_path(self):
28
+ self.assertFalse(is_valid_git_repo(Path("/tmp/nonexistent_12345")))
29
+
30
+ def test_returns_false_for_file(self):
31
+ with tempfile.TemporaryDirectory() as d:
32
+ f = Path(d) / "not_a_dir.txt"
33
+ f.write_text("hello", encoding="utf-8")
34
+ self.assertFalse(is_valid_git_repo(f))
35
+
36
+ def test_returns_false_for_dir_without_dot_git(self):
37
+ with tempfile.TemporaryDirectory() as d:
38
+ base = Path(d) / "plain_dir"
39
+ base.mkdir()
40
+ self.assertFalse(is_valid_git_repo(base))
41
+
42
+ def test_accepts_path_object(self):
43
+ with tempfile.TemporaryDirectory() as d:
44
+ base = Path(d)
45
+ (base / ".git").mkdir()
46
+ self.assertTrue(is_valid_git_repo(Path(d)))
47
+
48
+ def test_accepts_string_path(self):
49
+ with tempfile.TemporaryDirectory() as d:
50
+ base = Path(d)
51
+ (base / ".git").mkdir()
52
+ # is_valid_git_repo coerces to Path internally
53
+ self.assertTrue(is_valid_git_repo(d))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
@@ -0,0 +1,192 @@
1
+ """Tests for the coverage subcommand."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import patch
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import coverage
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _make_cfg(repos=None):
21
+ if repos is None:
22
+ repos = {"myrepo": {"github": "org/myrepo", "local": "/tmp/myrepo"}}
23
+ return {"notes_root": "/tmp/notes", "repos": repos}
24
+
25
+
26
+ def _make_track(name, repo, issue_nums, status="active"):
27
+ return SimpleNamespace(
28
+ name=name,
29
+ repo=repo,
30
+ has_frontmatter=True,
31
+ meta={"status": status, "github": {"repo": repo, "issues": issue_nums}},
32
+ )
33
+
34
+
35
+ def _run(args, *, cfg, tracks, open_issues_by_repo):
36
+ """Run coverage.run with mocked config, tracks, and gh calls."""
37
+ def _mock_open_issues(repo, limit=1000):
38
+ return open_issues_by_repo.get(repo, [])
39
+
40
+ buf = io.StringIO()
41
+ with patch("commands.coverage.load_config", return_value=cfg), \
42
+ patch("commands.coverage.discover_tracks", return_value=tracks), \
43
+ patch("commands.coverage.fetch_open_issues", side_effect=_mock_open_issues), \
44
+ redirect_stdout(buf):
45
+ rc = coverage.run(args)
46
+ return rc, buf.getvalue()
47
+
48
+
49
+ def _issues(*numbers):
50
+ return [{"number": n, "title": f"Issue {n}", "state": "OPEN"} for n in numbers]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Tests
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class CoverageBasicTest(unittest.TestCase):
58
+
59
+ def test_all_tracked_reports_full_coverage(self):
60
+ cfg = _make_cfg()
61
+ tracks = [_make_track("t1", "org/myrepo", [1, 2, 3])]
62
+ rc, out = _run([], cfg=cfg, tracks=tracks,
63
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
64
+ self.assertEqual(rc, 0)
65
+ self.assertIn("full coverage", out)
66
+ self.assertIn("Untracked: 0", out)
67
+
68
+ def test_partial_coverage_shows_count_and_percent(self):
69
+ cfg = _make_cfg()
70
+ tracks = [_make_track("t1", "org/myrepo", [1, 2])]
71
+ rc, out = _run([], cfg=cfg, tracks=tracks,
72
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3, 4)})
73
+ self.assertEqual(rc, 0)
74
+ self.assertIn("Untracked: 2", out)
75
+ self.assertIn("50%", out)
76
+
77
+ def test_no_open_issues_reports_zero(self):
78
+ cfg = _make_cfg()
79
+ tracks = [_make_track("t1", "org/myrepo", [1])]
80
+ rc, out = _run([], cfg=cfg, tracks=tracks,
81
+ open_issues_by_repo={"org/myrepo": []})
82
+ self.assertEqual(rc, 0)
83
+ self.assertIn("No open issues", out)
84
+
85
+ def test_no_tracks_everything_untracked(self):
86
+ cfg = _make_cfg()
87
+ rc, out = _run([], cfg=cfg, tracks=[],
88
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
89
+ self.assertEqual(rc, 0)
90
+ self.assertIn("Untracked: 3", out)
91
+ self.assertIn("100%", out)
92
+
93
+
94
+ class CoverageRepoFlagTest(unittest.TestCase):
95
+
96
+ def test_repo_flag_scopes_to_one_repo(self):
97
+ cfg = _make_cfg(repos={
98
+ "repoA": {"github": "org/repoA"},
99
+ "repoB": {"github": "org/repoB"},
100
+ })
101
+ tracks = [
102
+ _make_track("tA", "org/repoA", [1]),
103
+ _make_track("tB", "org/repoB", [2]),
104
+ ]
105
+ rc, out = _run(["--repo=repoA"], cfg=cfg, tracks=tracks,
106
+ open_issues_by_repo={"org/repoA": _issues(1, 99),
107
+ "org/repoB": _issues(2, 98)})
108
+ self.assertEqual(rc, 0)
109
+ self.assertIn("repoA", out)
110
+ self.assertNotIn("repoB", out)
111
+
112
+ def test_unknown_repo_flag_returns_error(self):
113
+ cfg = _make_cfg()
114
+ rc, out = _run(["--repo=nope"], cfg=cfg, tracks=[],
115
+ open_issues_by_repo={})
116
+ self.assertEqual(rc, 1)
117
+ self.assertIn("ERROR", out)
118
+
119
+
120
+ class CoverageListFlagTest(unittest.TestCase):
121
+
122
+ def test_list_flag_shows_issue_titles(self):
123
+ cfg = _make_cfg()
124
+ tracks = [_make_track("t1", "org/myrepo", [1])]
125
+ rc, out = _run(["--list"], cfg=cfg, tracks=tracks,
126
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
127
+ self.assertEqual(rc, 0)
128
+ self.assertIn("Issue 2", out)
129
+ self.assertIn("Issue 3", out)
130
+
131
+ def test_list_flag_truncates_at_default_20(self):
132
+ cfg = _make_cfg()
133
+ open_nums = list(range(1, 26)) # 25 issues, none tracked
134
+ rc, out = _run(["--list"], cfg=cfg, tracks=[],
135
+ open_issues_by_repo={"org/myrepo": _issues(*open_nums)})
136
+ self.assertEqual(rc, 0)
137
+ self.assertIn("and 5 more", out)
138
+
139
+ def test_limit_flag_overrides_default(self):
140
+ cfg = _make_cfg()
141
+ open_nums = list(range(1, 11)) # 10 issues
142
+ rc, out = _run(["--list", "--limit=3"], cfg=cfg, tracks=[],
143
+ open_issues_by_repo={"org/myrepo": _issues(*open_nums)})
144
+ self.assertEqual(rc, 0)
145
+ self.assertIn("and 7 more", out)
146
+
147
+ def test_without_list_flag_no_titles_shown(self):
148
+ cfg = _make_cfg()
149
+ tracks = [_make_track("t1", "org/myrepo", [1])]
150
+ rc, out = _run([], cfg=cfg, tracks=tracks,
151
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
152
+ self.assertEqual(rc, 0)
153
+ self.assertNotIn("Issue 2", out)
154
+ self.assertNotIn("Issue 3", out)
155
+ self.assertIn("--list", out) # hint printed
156
+
157
+ def test_exact_limit_no_remainder_line(self):
158
+ cfg = _make_cfg()
159
+ rc, out = _run(["--list", "--limit=3"], cfg=cfg, tracks=[],
160
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
161
+ self.assertEqual(rc, 0)
162
+ self.assertNotIn("more", out)
163
+
164
+
165
+ class CoverageMultiRepoTest(unittest.TestCase):
166
+
167
+ def test_all_repos_reported_when_no_repo_flag(self):
168
+ cfg = _make_cfg(repos={
169
+ "repoA": {"github": "org/repoA"},
170
+ "repoB": {"github": "org/repoB"},
171
+ })
172
+ tracks = [_make_track("tA", "org/repoA", [1])]
173
+ rc, out = _run([], cfg=cfg, tracks=tracks,
174
+ open_issues_by_repo={"org/repoA": _issues(1, 2),
175
+ "org/repoB": _issues(3, 4)})
176
+ self.assertEqual(rc, 0)
177
+ self.assertIn("repoA", out)
178
+ self.assertIn("repoB", out)
179
+
180
+ def test_tracks_without_frontmatter_ignored(self):
181
+ cfg = _make_cfg()
182
+ no_fm = SimpleNamespace(name="orphan", repo="org/myrepo",
183
+ has_frontmatter=False, meta={})
184
+ rc, out = _run([], cfg=cfg, tracks=[no_fm],
185
+ open_issues_by_repo={"org/myrepo": _issues(1, 2)})
186
+ self.assertEqual(rc, 0)
187
+ # Both issues should be untracked since the track has no frontmatter
188
+ self.assertIn("Untracked: 2", out)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ unittest.main()
@@ -6,10 +6,11 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_R
6
6
  from lib.export_model import build_export
7
7
  import commands.export as export_cmd
8
8
 
9
- def _track(name, repo, issues, blockers=None, next_up=None, status="active"):
9
+ def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
10
10
  return SimpleNamespace(name=name, repo=repo, tier="private",
11
11
  meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
12
12
  "blockers": blockers or [], "next_up": next_up or [],
13
+ "depends_on": depends_on or [],
13
14
  "github": {"repo": repo, "issues": issues}})
14
15
 
15
16
  class BuildExportTest(unittest.TestCase):
@@ -29,6 +30,49 @@ class BuildExportTest(unittest.TestCase):
29
30
  self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
30
31
  json.dumps(out) # must be serializable
31
32
 
33
+ class BuildExportNextUpFilterTest(unittest.TestCase):
34
+ """next_up entries whose issue is closed in the fetched payload are filtered out."""
35
+
36
+ def _build(self, next_up_nums, issue_states):
37
+ """Build export where issues have given states; return the track's next_up."""
38
+ raw_issues = [
39
+ {"number": n, "title": f"i{n}", "state": state, "assignees": []}
40
+ for n, state in issue_states.items()
41
+ ]
42
+ tracks = [_track("t1", "o/r", list(issue_states.keys()), next_up=next_up_nums)]
43
+ out = build_export(tracks, {"t1": raw_issues}, {"o/r": "PRIVATE"}, now="t")
44
+ return out["tracks"][0]["next_up"]
45
+
46
+ def test_closed_next_up_filtered(self):
47
+ """Closed issue in next_up is removed from the export payload."""
48
+ result = self._build([95], {95: "CLOSED"})
49
+ self.assertEqual(result, [])
50
+
51
+ def test_open_next_up_kept(self):
52
+ """Open issue in next_up is kept."""
53
+ result = self._build([95], {95: "OPEN"})
54
+ self.assertEqual(result, [95])
55
+
56
+ def test_mixed_next_up_only_open_kept(self):
57
+ """Mixed next_up: closed issue removed, open issue kept."""
58
+ result = self._build([95, 96], {95: "CLOSED", 96: "OPEN"})
59
+ self.assertEqual(result, [96])
60
+
61
+ def test_next_up_issue_not_in_fetched_payload_kept(self):
62
+ """If a next_up issue wasn't fetched (e.g. not in the track's issue list),
63
+ it's preserved rather than silently dropped — we only remove confirmed-closed."""
64
+ tracks = [_track("t1", "o/r", [95], next_up=[95, 200])]
65
+ raw_issues = [{"number": 95, "title": "t", "state": "CLOSED", "assignees": []}]
66
+ out = build_export(tracks, {"t1": raw_issues}, {"o/r": "PRIVATE"}, now="t")
67
+ result = out["tracks"][0]["next_up"]
68
+ # 95 is confirmed closed → filtered; 200 not in payload → kept
69
+ self.assertEqual(result, [200])
70
+
71
+ def test_empty_next_up_unchanged(self):
72
+ result = self._build([], {95: "OPEN"})
73
+ self.assertEqual(result, [])
74
+
75
+
32
76
  class BuildExportUntrackedTest(unittest.TestCase):
33
77
  """Tests for the untracked kwarg on build_export."""
34
78
 
@@ -86,6 +130,165 @@ class BuildExportUntrackedTest(unittest.TestCase):
86
130
  json.dumps(out) # must not raise
87
131
 
88
132
 
133
+ class BuildExportTierFieldTest(unittest.TestCase):
134
+ """Tests that build_export uses the track's actual tier field."""
135
+
136
+ def _build(self, tier_value):
137
+ """Build a minimal export with a track that has the given tier."""
138
+ from types import SimpleNamespace
139
+ t = SimpleNamespace(
140
+ name="t1",
141
+ repo="o/r",
142
+ tier=tier_value,
143
+ meta={
144
+ "status": "active",
145
+ "launch_priority": "P2",
146
+ "milestone_alignment": "v1",
147
+ "blockers": [],
148
+ "next_up": [],
149
+ "github": {"repo": "o/r", "issues": []},
150
+ },
151
+ )
152
+ out = build_export([t], {}, {"o/r": "PRIVATE"}, now="2026-06-09T00:00")
153
+ return out["tracks"][0]["tier"]
154
+
155
+ def test_tier_shared_exported_as_shared(self):
156
+ """Track with tier='shared' → export JSON has tier='shared'."""
157
+ self.assertEqual(self._build("shared"), "shared")
158
+
159
+ def test_tier_private_exported_as_private(self):
160
+ """Track with tier='private' → export JSON has tier='private'."""
161
+ self.assertEqual(self._build("private"), "private")
162
+
163
+ def test_tier_none_exported_as_private(self):
164
+ """Track with tier=None → export JSON has tier='private' (safe default)."""
165
+ self.assertEqual(self._build(None), "private")
166
+
167
+
89
168
  class ExportCommandGateTest(unittest.TestCase):
90
169
  def test_requires_json_flag(self):
91
170
  self.assertEqual(export_cmd.run([]), 2)
171
+
172
+
173
+ class MilestoneSortKeyTest(unittest.TestCase):
174
+ """Tests for milestone_sort_key — the sort-order function."""
175
+
176
+ def test_active_milestone_first(self):
177
+ from lib.export_model import milestone_sort_key
178
+ active = {"number": 10, "milestone": "v1"}
179
+ future = {"number": 20, "milestone": "v2"}
180
+ # active milestone (matches alignment) should sort before future
181
+ self.assertLess(
182
+ milestone_sort_key(active, milestone_alignment="v1"),
183
+ milestone_sort_key(future, milestone_alignment="v1"),
184
+ )
185
+
186
+ def test_future_before_null(self):
187
+ from lib.export_model import milestone_sort_key
188
+ future = {"number": 10, "milestone": "v2"}
189
+ null_ms = {"number": 99, "milestone": None}
190
+ self.assertLess(
191
+ milestone_sort_key(future, milestone_alignment="v1"),
192
+ milestone_sort_key(null_ms, milestone_alignment="v1"),
193
+ )
194
+
195
+ def test_null_last(self):
196
+ from lib.export_model import milestone_sort_key
197
+ null_ms = {"number": 10, "milestone": None}
198
+ active = {"number": 20, "milestone": "v1"}
199
+ self.assertLess(
200
+ milestone_sort_key(active, milestone_alignment="v1"),
201
+ milestone_sort_key(null_ms, milestone_alignment="v1"),
202
+ )
203
+
204
+ def test_number_tiebreak_within_group(self):
205
+ from lib.export_model import milestone_sort_key
206
+ a = {"number": 10, "milestone": "v1"}
207
+ b = {"number": 5, "milestone": "v1"}
208
+ # Both match alignment → tier 0; lower number sorts first
209
+ self.assertLess(
210
+ milestone_sort_key(b, milestone_alignment="v1"),
211
+ milestone_sort_key(a, milestone_alignment="v1"),
212
+ )
213
+
214
+ def test_empty_string_milestone_treated_as_null(self):
215
+ from lib.export_model import milestone_sort_key
216
+ empty = {"number": 1, "milestone": ""}
217
+ null_ms = {"number": 2, "milestone": None}
218
+ # Both should be in tier 2
219
+ k1 = milestone_sort_key(empty, milestone_alignment="v1")
220
+ k2 = milestone_sort_key(null_ms, milestone_alignment="v1")
221
+ self.assertEqual(k1[0], 2) # tier
222
+ self.assertEqual(k2[0], 2)
223
+
224
+
225
+ class GroupIssuesByMilestoneTest(unittest.TestCase):
226
+ """Tests for group_issues_by_milestone."""
227
+
228
+ def test_single_group_returns_one_entry(self):
229
+ from lib.export_model import group_issues_by_milestone
230
+ issues = [
231
+ {"number": 1, "milestone": "v1"},
232
+ {"number": 2, "milestone": "v1"},
233
+ ]
234
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
235
+ self.assertEqual(len(groups), 1)
236
+ label, items = groups[0]
237
+ self.assertEqual(label, "v1")
238
+ self.assertEqual([i["number"] for i in items], [1, 2])
239
+
240
+ def test_all_null_returns_single_group(self):
241
+ from lib.export_model import group_issues_by_milestone
242
+ issues = [
243
+ {"number": 2, "milestone": None},
244
+ {"number": 1, "milestone": None},
245
+ ]
246
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
247
+ self.assertEqual(len(groups), 1)
248
+ label, items = groups[0]
249
+ self.assertIsNone(label)
250
+ # Sorted by number within the null group
251
+ self.assertEqual([i["number"] for i in items], [1, 2])
252
+
253
+ def test_multi_group_active_first(self):
254
+ from lib.export_model import group_issues_by_milestone
255
+ issues = [
256
+ {"number": 30, "milestone": None},
257
+ {"number": 20, "milestone": "v2"},
258
+ {"number": 10, "milestone": "v1"},
259
+ ]
260
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
261
+ self.assertEqual(len(groups), 3)
262
+ # Active milestone (v1) first
263
+ self.assertEqual(groups[0][0], "v1")
264
+ self.assertEqual([i["number"] for i in groups[0][1]], [10])
265
+ # Future (v2) second
266
+ self.assertEqual(groups[1][0], "v2")
267
+ self.assertEqual([i["number"] for i in groups[1][1]], [20])
268
+ # Null last
269
+ self.assertIsNone(groups[2][0])
270
+ self.assertEqual([i["number"] for i in groups[2][1]], [30])
271
+
272
+ def test_empty_issues_returns_empty(self):
273
+ from lib.export_model import group_issues_by_milestone
274
+ self.assertEqual(group_issues_by_milestone([]), [])
275
+
276
+
277
+ class BuildExportDependsOnTest(unittest.TestCase):
278
+ """Tests that depends_on is surfaced in the export JSON (#102)."""
279
+
280
+ def test_depends_on_exported(self):
281
+ tracks = [_track("alpha", "o/r", [1], depends_on=["beta", "gamma"])]
282
+ issues_by_track = {"alpha": [
283
+ {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
284
+ ]}
285
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
286
+ self.assertEqual(out["tracks"][0]["depends_on"], ["beta", "gamma"])
287
+
288
+ def test_depends_on_empty_by_default(self):
289
+ tracks = [_track("alpha", "o/r", [1])]
290
+ issues_by_track = {"alpha": [
291
+ {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
292
+ ]}
293
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
294
+ self.assertEqual(out["tracks"][0]["depends_on"], [])
@@ -72,12 +72,12 @@ class ExportRunJsonTest(unittest.TestCase):
72
72
  self.assertEqual(out["schema"], 1)
73
73
 
74
74
  def test_track_issues_assembled_in_declared_order(self):
75
- # Track declares [2, 1] output order must match declaration, not map-insertion order
75
+ # Issues are milestone-sorted (#101): null-milestone group sorts by number.
76
76
  tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
77
77
  rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
78
78
  self.assertEqual(rc, 0)
79
79
  issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
80
- self.assertEqual(issue_nums, [2, 1])
80
+ self.assertEqual(issue_nums, [1, 2])
81
81
 
82
82
  def test_shared_issue_appears_in_both_tracks(self):
83
83
  tracks = [