@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.
- package/README.md +91 -13
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +71 -17
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +17 -5
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +39 -7
- package/skills/work-plan/commands/init_repo.py +43 -1
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +100 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +19 -6
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +208 -18
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +128 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +169 -4
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +395 -1
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- 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
|
-
#
|
|
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, [
|
|
80
|
+
self.assertEqual(issue_nums, [1, 2])
|
|
81
81
|
|
|
82
82
|
def test_shared_issue_appears_in_both_tracks(self):
|
|
83
83
|
tracks = [
|