@stylusnexus/work-plan 2026.6.9
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/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +210 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tests for drift detection."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.drift import detect_drift
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DetectDriftTest(unittest.TestCase):
|
|
13
|
+
def test_no_drift_when_table_matches(self):
|
|
14
|
+
body = (
|
|
15
|
+
"| # | Title | Status |\n"
|
|
16
|
+
"|---|---|---|\n"
|
|
17
|
+
"| #1 | foo | ✅ Shipped |\n"
|
|
18
|
+
)
|
|
19
|
+
github_issues = [{"number": 1, "state": "CLOSED"}]
|
|
20
|
+
self.assertEqual(detect_drift(body, github_issues), [])
|
|
21
|
+
|
|
22
|
+
def test_drift_when_open_in_md_closed_in_github(self):
|
|
23
|
+
body = (
|
|
24
|
+
"| # | Title | Status |\n"
|
|
25
|
+
"|---|---|---|\n"
|
|
26
|
+
"| #1 | foo | 🔲 Open |\n"
|
|
27
|
+
)
|
|
28
|
+
github_issues = [{"number": 1, "state": "CLOSED"}]
|
|
29
|
+
drift = detect_drift(body, github_issues)
|
|
30
|
+
self.assertEqual(len(drift), 1)
|
|
31
|
+
self.assertEqual(drift[0]["issue"], 1)
|
|
32
|
+
|
|
33
|
+
def test_no_table_returns_empty(self):
|
|
34
|
+
self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
unittest.main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# tests/test_export.py
|
|
2
|
+
import sys, json, unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
6
|
+
from lib.export_model import build_export
|
|
7
|
+
import commands.export as export_cmd
|
|
8
|
+
|
|
9
|
+
def _track(name, repo, issues, blockers=None, next_up=None, status="active"):
|
|
10
|
+
return SimpleNamespace(name=name, repo=repo, tier="private",
|
|
11
|
+
meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
|
|
12
|
+
"blockers": blockers or [], "next_up": next_up or [],
|
|
13
|
+
"github": {"repo": repo, "issues": issues}})
|
|
14
|
+
|
|
15
|
+
class BuildExportTest(unittest.TestCase):
|
|
16
|
+
def test_schema_and_shape(self):
|
|
17
|
+
tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
|
|
18
|
+
issues_by_track = {"ph": [
|
|
19
|
+
{"number": 1, "title": "a", "state": "OPEN", "assignees": [{"login": "eve"}]},
|
|
20
|
+
{"number": 2, "title": "b", "state": "CLOSED", "assignees": []}]}
|
|
21
|
+
vis = {"o/r": "PRIVATE"}
|
|
22
|
+
out = build_export(tracks, issues_by_track, vis, now="2026-06-07T00:00")
|
|
23
|
+
self.assertEqual(out["schema"], 1)
|
|
24
|
+
t = out["tracks"][0]
|
|
25
|
+
self.assertEqual(t["name"], "ph"); self.assertEqual(t["tier"], "private")
|
|
26
|
+
self.assertEqual(t["visibility"], "PRIVATE")
|
|
27
|
+
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
28
|
+
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
29
|
+
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
|
|
30
|
+
json.dumps(out) # must be serializable
|
|
31
|
+
|
|
32
|
+
class BuildExportUntrackedTest(unittest.TestCase):
|
|
33
|
+
"""Tests for the untracked kwarg on build_export."""
|
|
34
|
+
|
|
35
|
+
_RAW_ROW = {"number": 9, "title": "x", "state": "OPEN", "assignees": [], "milestone": None}
|
|
36
|
+
|
|
37
|
+
def test_untracked_key_present_when_omitted(self):
|
|
38
|
+
"""Back-compat: callers that omit untracked_by_repo still get out['untracked'] == []."""
|
|
39
|
+
out = build_export([], {}, {}, now="2026-06-07T00:00")
|
|
40
|
+
self.assertIn("untracked", out)
|
|
41
|
+
self.assertEqual(out["untracked"], [])
|
|
42
|
+
|
|
43
|
+
def test_untracked_key_present_when_none(self):
|
|
44
|
+
out = build_export([], {}, {}, now="2026-06-07T00:00", untracked_by_repo=None)
|
|
45
|
+
self.assertEqual(out["untracked"], [])
|
|
46
|
+
|
|
47
|
+
def test_untracked_populated(self):
|
|
48
|
+
out = build_export(
|
|
49
|
+
[], {}, {}, now="2026-06-07T00:00",
|
|
50
|
+
untracked_by_repo={"o/r": [self._RAW_ROW]},
|
|
51
|
+
)
|
|
52
|
+
self.assertEqual(len(out["untracked"]), 1)
|
|
53
|
+
entry = out["untracked"][0]
|
|
54
|
+
self.assertEqual(entry["repo"], "o/r")
|
|
55
|
+
self.assertEqual(len(entry["issues"]), 1)
|
|
56
|
+
# _issue normalises state to lowercase "open"
|
|
57
|
+
issue = entry["issues"][0]
|
|
58
|
+
self.assertEqual(issue["number"], 9)
|
|
59
|
+
self.assertEqual(issue["title"], "x")
|
|
60
|
+
self.assertEqual(issue["state"], "open")
|
|
61
|
+
|
|
62
|
+
def test_empty_rows_repo_omitted(self):
|
|
63
|
+
out = build_export(
|
|
64
|
+
[], {}, {}, now="2026-06-07T00:00",
|
|
65
|
+
untracked_by_repo={"o/r": [], "o/q": [self._RAW_ROW]},
|
|
66
|
+
)
|
|
67
|
+
repos = [e["repo"] for e in out["untracked"]]
|
|
68
|
+
self.assertNotIn("o/r", repos)
|
|
69
|
+
self.assertIn("o/q", repos)
|
|
70
|
+
|
|
71
|
+
def test_insertion_order_preserved(self):
|
|
72
|
+
row_a = {"number": 1, "title": "a", "state": "OPEN", "assignees": [], "milestone": None}
|
|
73
|
+
row_b = {"number": 2, "title": "b", "state": "OPEN", "assignees": [], "milestone": None}
|
|
74
|
+
# Python 3.7+ dicts are ordered; pass in explicit order
|
|
75
|
+
untracked = {"repo/b": [row_b], "repo/a": [row_a]}
|
|
76
|
+
out = build_export([], {}, {}, now="t", untracked_by_repo=untracked)
|
|
77
|
+
repos = [e["repo"] for e in out["untracked"]]
|
|
78
|
+
self.assertEqual(repos, ["repo/b", "repo/a"])
|
|
79
|
+
|
|
80
|
+
def test_schema_stays_1(self):
|
|
81
|
+
out = build_export([], {}, {}, now="t", untracked_by_repo={"o/r": [self._RAW_ROW]})
|
|
82
|
+
self.assertEqual(out["schema"], 1)
|
|
83
|
+
|
|
84
|
+
def test_json_serializable(self):
|
|
85
|
+
out = build_export([], {}, {}, now="t", untracked_by_repo={"o/r": [self._RAW_ROW]})
|
|
86
|
+
json.dumps(out) # must not raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ExportCommandGateTest(unittest.TestCase):
|
|
90
|
+
def test_requires_json_flag(self):
|
|
91
|
+
self.assertEqual(export_cmd.run([]), 2)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# tests/test_export_command.py
|
|
2
|
+
"""Tests for the export command's bulk fetch path."""
|
|
3
|
+
import sys, json, unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import patch, MagicMock, call
|
|
7
|
+
|
|
8
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
10
|
+
|
|
11
|
+
import commands.export as export_cmd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _track(name, repo, issues, *, has_frontmatter=True, status="active"):
|
|
15
|
+
return SimpleNamespace(
|
|
16
|
+
name=name,
|
|
17
|
+
repo=repo,
|
|
18
|
+
tier="private",
|
|
19
|
+
has_frontmatter=has_frontmatter,
|
|
20
|
+
meta={
|
|
21
|
+
"status": status,
|
|
22
|
+
"launch_priority": "P2",
|
|
23
|
+
"milestone_alignment": "v1",
|
|
24
|
+
"blockers": [],
|
|
25
|
+
"next_up": [],
|
|
26
|
+
"github": {"repo": repo, "issues": issues},
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_ISSUE_A = {"number": 1, "title": "Alpha", "state": "OPEN",
|
|
32
|
+
"assignees": [{"login": "eve"}], "milestone": None}
|
|
33
|
+
_ISSUE_B = {"number": 2, "title": "Beta", "state": "CLOSED",
|
|
34
|
+
"assignees": [], "milestone": None}
|
|
35
|
+
_ISSUE_C = {"number": 3, "title": "Gamma", "state": "OPEN",
|
|
36
|
+
"assignees": [], "milestone": None}
|
|
37
|
+
|
|
38
|
+
# Shared issue key — issue 1 is referenced by BOTH tracks
|
|
39
|
+
_SHARED_REPO = "org/shared"
|
|
40
|
+
_EXPORT_MAP = {
|
|
41
|
+
(_SHARED_REPO, 1): _ISSUE_A,
|
|
42
|
+
(_SHARED_REPO, 2): _ISSUE_B,
|
|
43
|
+
(_SHARED_REPO, 3): _ISSUE_C,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExportRunJsonTest(unittest.TestCase):
|
|
48
|
+
"""Drive export.run(["--json"]) with mocked deps; verify schema + assembly."""
|
|
49
|
+
|
|
50
|
+
def _run_with_mocks(self, tracks, export_map, vis=None):
|
|
51
|
+
"""Helper: run the export command with controlled mocks, capture stdout."""
|
|
52
|
+
import io
|
|
53
|
+
from contextlib import redirect_stdout
|
|
54
|
+
|
|
55
|
+
vis = vis or {_SHARED_REPO: "PUBLIC"}
|
|
56
|
+
|
|
57
|
+
with patch("commands.export.load_config", return_value={}), \
|
|
58
|
+
patch("commands.export.discover_tracks", return_value=tracks), \
|
|
59
|
+
patch("commands.export.fetch_export_issues", return_value=export_map) as mock_fei, \
|
|
60
|
+
patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
|
|
61
|
+
patch("commands.export.datetime") as mock_dt:
|
|
62
|
+
mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
|
|
63
|
+
buf = io.StringIO()
|
|
64
|
+
with redirect_stdout(buf):
|
|
65
|
+
rc = export_cmd.run(["--json"])
|
|
66
|
+
return rc, json.loads(buf.getvalue()), mock_fei
|
|
67
|
+
|
|
68
|
+
def test_schema_is_1(self):
|
|
69
|
+
tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
|
|
70
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
71
|
+
self.assertEqual(rc, 0)
|
|
72
|
+
self.assertEqual(out["schema"], 1)
|
|
73
|
+
|
|
74
|
+
def test_track_issues_assembled_in_declared_order(self):
|
|
75
|
+
# Track declares [2, 1] — output order must match declaration, not map-insertion order
|
|
76
|
+
tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
|
|
77
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
|
|
80
|
+
self.assertEqual(issue_nums, [2, 1])
|
|
81
|
+
|
|
82
|
+
def test_shared_issue_appears_in_both_tracks(self):
|
|
83
|
+
tracks = [
|
|
84
|
+
_track("alpha", _SHARED_REPO, [1, 2]),
|
|
85
|
+
_track("beta", _SHARED_REPO, [1, 3]),
|
|
86
|
+
]
|
|
87
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
88
|
+
self.assertEqual(rc, 0)
|
|
89
|
+
alpha_nums = {i["number"] for i in out["tracks"][0]["issues"]}
|
|
90
|
+
beta_nums = {i["number"] for i in out["tracks"][1]["issues"]}
|
|
91
|
+
self.assertIn(1, alpha_nums)
|
|
92
|
+
self.assertIn(1, beta_nums)
|
|
93
|
+
|
|
94
|
+
def test_deduped_repo_to_numbers_passed_to_bulk_fetch(self):
|
|
95
|
+
"""Issues shared by two tracks in the same repo should be in the
|
|
96
|
+
repo_to_numbers dict only ONCE per repo (deduplication)."""
|
|
97
|
+
tracks = [
|
|
98
|
+
_track("alpha", _SHARED_REPO, [1, 2]),
|
|
99
|
+
_track("beta", _SHARED_REPO, [1, 3]),
|
|
100
|
+
]
|
|
101
|
+
rc, out, mock_fei = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
102
|
+
self.assertEqual(rc, 0)
|
|
103
|
+
# fetch_export_issues called with repo_to_numbers dict
|
|
104
|
+
repo_to_numbers = mock_fei.call_args[0][0]
|
|
105
|
+
nums = repo_to_numbers.get(_SHARED_REPO, [])
|
|
106
|
+
# issue 1 should appear exactly once
|
|
107
|
+
self.assertEqual(nums.count(1), 1)
|
|
108
|
+
# total unique: 1, 2, 3
|
|
109
|
+
self.assertEqual(sorted(nums), [1, 2, 3])
|
|
110
|
+
|
|
111
|
+
def test_missing_fetch_result_is_skipped(self):
|
|
112
|
+
# Issue 99 is in the track but not in the export map (simulates PR/miss)
|
|
113
|
+
track = _track("alpha", _SHARED_REPO, [1, 99])
|
|
114
|
+
partial_map = {(_SHARED_REPO, 1): _ISSUE_A} # 99 absent
|
|
115
|
+
rc, out, _ = self._run_with_mocks([track], partial_map)
|
|
116
|
+
self.assertEqual(rc, 0)
|
|
117
|
+
issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
|
|
118
|
+
self.assertEqual(issue_nums, [1]) # 99 skipped
|
|
119
|
+
|
|
120
|
+
def test_track_without_repo_gets_empty_issues(self):
|
|
121
|
+
track = _track("norep", None, [1, 2])
|
|
122
|
+
rc, out, mock_fei = self._run_with_mocks([track], {})
|
|
123
|
+
self.assertEqual(rc, 0)
|
|
124
|
+
self.assertEqual(out["tracks"][0]["issues"], [])
|
|
125
|
+
# repo_to_numbers should be empty (no repo)
|
|
126
|
+
repo_to_numbers = mock_fei.call_args[0][0]
|
|
127
|
+
self.assertEqual(repo_to_numbers, {})
|
|
128
|
+
|
|
129
|
+
def test_track_without_issues_gets_empty_issues(self):
|
|
130
|
+
track = _track("noissues", _SHARED_REPO, [])
|
|
131
|
+
rc, out, _ = self._run_with_mocks([track], {})
|
|
132
|
+
self.assertEqual(rc, 0)
|
|
133
|
+
self.assertEqual(out["tracks"][0]["issues"], [])
|
|
134
|
+
|
|
135
|
+
def test_visibility_included_in_output(self):
|
|
136
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
137
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"})
|
|
138
|
+
self.assertEqual(out["tracks"][0]["visibility"], "PUBLIC")
|
|
139
|
+
|
|
140
|
+
def test_rollup_counts_correct(self):
|
|
141
|
+
tracks = [_track("alpha", _SHARED_REPO, [1, 2])] # 1=OPEN, 2=CLOSED
|
|
142
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
143
|
+
rollup = out["tracks"][0]["rollup"]
|
|
144
|
+
self.assertEqual(rollup["open"], 1)
|
|
145
|
+
self.assertEqual(rollup["closed"], 1)
|
|
146
|
+
|
|
147
|
+
def test_no_frontmatter_tracks_excluded(self):
|
|
148
|
+
tracks = [
|
|
149
|
+
_track("with_fm", _SHARED_REPO, [1], has_frontmatter=True),
|
|
150
|
+
_track("without_fm", _SHARED_REPO, [2], has_frontmatter=False),
|
|
151
|
+
]
|
|
152
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
153
|
+
self.assertEqual(rc, 0)
|
|
154
|
+
track_names = [t["name"] for t in out["tracks"]]
|
|
155
|
+
self.assertIn("with_fm", track_names)
|
|
156
|
+
self.assertNotIn("without_fm", track_names)
|
|
157
|
+
|
|
158
|
+
def test_output_is_json_serializable(self):
|
|
159
|
+
tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
|
|
160
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
161
|
+
self.assertEqual(rc, 0)
|
|
162
|
+
json.dumps(out) # must not raise
|
|
163
|
+
|
|
164
|
+
def test_issue_absent_from_map_even_after_fallback_is_omitted(self):
|
|
165
|
+
"""An issue referenced by a track but absent from the returned map
|
|
166
|
+
(simulating a PR/miss where even the fallback didn't find it) must be
|
|
167
|
+
silently omitted from that track's issues list."""
|
|
168
|
+
track = _track("alpha", _SHARED_REPO, [1, 2, 999])
|
|
169
|
+
partial_map = {(_SHARED_REPO, 1): _ISSUE_A, (_SHARED_REPO, 2): _ISSUE_B}
|
|
170
|
+
# 999 is not in the map at all
|
|
171
|
+
rc, out, _ = self._run_with_mocks([track], partial_map)
|
|
172
|
+
self.assertEqual(rc, 0)
|
|
173
|
+
issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
|
|
174
|
+
self.assertNotIn(999, issue_nums)
|
|
175
|
+
self.assertIn(1, issue_nums)
|
|
176
|
+
self.assertIn(2, issue_nums)
|
|
177
|
+
|
|
178
|
+
def test_two_repos_deduped_independently(self):
|
|
179
|
+
"""Each repo's number list is deduped independently; a number shared
|
|
180
|
+
across repos is still fetched once per repo."""
|
|
181
|
+
repo_a = "org/repoA"
|
|
182
|
+
repo_b = "org/repoB"
|
|
183
|
+
tracks = [
|
|
184
|
+
_track("alpha", repo_a, [1, 2]),
|
|
185
|
+
_track("beta", repo_a, [1, 3]), # issue 1 shared in repoA
|
|
186
|
+
_track("gamma", repo_b, [1]), # issue 1 in repoB is a different issue
|
|
187
|
+
]
|
|
188
|
+
export_map = {
|
|
189
|
+
(repo_a, 1): {"number": 1, "title": "A1", "state": "OPEN", "assignees": [], "milestone": None},
|
|
190
|
+
(repo_a, 2): {"number": 2, "title": "A2", "state": "OPEN", "assignees": [], "milestone": None},
|
|
191
|
+
(repo_a, 3): {"number": 3, "title": "A3", "state": "OPEN", "assignees": [], "milestone": None},
|
|
192
|
+
(repo_b, 1): {"number": 1, "title": "B1", "state": "OPEN", "assignees": [], "milestone": None},
|
|
193
|
+
}
|
|
194
|
+
vis = {repo_a: "PUBLIC", repo_b: "PUBLIC"}
|
|
195
|
+
rc, out, mock_fei = self._run_with_mocks(tracks, export_map, vis=vis)
|
|
196
|
+
self.assertEqual(rc, 0)
|
|
197
|
+
repo_to_numbers = mock_fei.call_args[0][0]
|
|
198
|
+
# repoA: issues 1, 2, 3 — each once
|
|
199
|
+
self.assertEqual(sorted(repo_to_numbers[repo_a]), [1, 2, 3])
|
|
200
|
+
self.assertEqual(repo_to_numbers[repo_a].count(1), 1)
|
|
201
|
+
# repoB: issue 1 — once
|
|
202
|
+
self.assertEqual(repo_to_numbers[repo_b], [1])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ExportCommandUntrackedTest(unittest.TestCase):
|
|
206
|
+
"""Verify export.run computes untracked = open issues minus tracked ones."""
|
|
207
|
+
|
|
208
|
+
def _run_with_mocks(self, tracks, export_map, open_rows_by_repo, vis=None):
|
|
209
|
+
import io
|
|
210
|
+
from contextlib import redirect_stdout
|
|
211
|
+
|
|
212
|
+
vis = vis or {_SHARED_REPO: "PUBLIC"}
|
|
213
|
+
|
|
214
|
+
def _fake_open_issues(repo, limit=1000):
|
|
215
|
+
return open_rows_by_repo.get(repo, [])
|
|
216
|
+
|
|
217
|
+
with patch("commands.export.load_config", return_value={}), \
|
|
218
|
+
patch("commands.export.discover_tracks", return_value=tracks), \
|
|
219
|
+
patch("commands.export.fetch_export_issues", return_value=export_map), \
|
|
220
|
+
patch("commands.export.fetch_open_issues", side_effect=_fake_open_issues), \
|
|
221
|
+
patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
|
|
222
|
+
patch("commands.export.datetime") as mock_dt:
|
|
223
|
+
mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
|
|
224
|
+
buf = io.StringIO()
|
|
225
|
+
with redirect_stdout(buf):
|
|
226
|
+
rc = export_cmd.run(["--json"])
|
|
227
|
+
return rc, json.loads(buf.getvalue())
|
|
228
|
+
|
|
229
|
+
def test_untracked_key_present_in_output(self):
|
|
230
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
231
|
+
rc, out = self._run_with_mocks(tracks, {(_SHARED_REPO, 1): _ISSUE_A}, {})
|
|
232
|
+
self.assertEqual(rc, 0)
|
|
233
|
+
self.assertIn("untracked", out)
|
|
234
|
+
|
|
235
|
+
def test_open_minus_tracked_yields_untracked(self):
|
|
236
|
+
"""Issues 1+2 are open; only 1 is tracked. Issue 2 is untracked."""
|
|
237
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
238
|
+
export_map = {(_SHARED_REPO, 1): _ISSUE_A}
|
|
239
|
+
# gh reports issues 1 and 2 as open
|
|
240
|
+
open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
|
|
241
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
242
|
+
self.assertEqual(rc, 0)
|
|
243
|
+
untracked = out["untracked"]
|
|
244
|
+
self.assertEqual(len(untracked), 1)
|
|
245
|
+
entry = untracked[0]
|
|
246
|
+
self.assertEqual(entry["repo"], _SHARED_REPO)
|
|
247
|
+
untracked_nums = [i["number"] for i in entry["issues"]]
|
|
248
|
+
self.assertNotIn(1, untracked_nums) # tracked — must be absent
|
|
249
|
+
self.assertIn(2, untracked_nums) # untracked — must appear
|
|
250
|
+
|
|
251
|
+
def test_all_tracked_yields_empty_untracked(self):
|
|
252
|
+
tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
|
|
253
|
+
export_map = {(_SHARED_REPO, 1): _ISSUE_A, (_SHARED_REPO, 2): _ISSUE_B}
|
|
254
|
+
open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
|
|
255
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
256
|
+
self.assertEqual(rc, 0)
|
|
257
|
+
# Either empty list or no entry for this repo
|
|
258
|
+
all_issue_nums = [
|
|
259
|
+
i["number"]
|
|
260
|
+
for entry in out["untracked"]
|
|
261
|
+
if entry["repo"] == _SHARED_REPO
|
|
262
|
+
for i in entry["issues"]
|
|
263
|
+
]
|
|
264
|
+
self.assertEqual(all_issue_nums, [])
|
|
265
|
+
|
|
266
|
+
def test_no_open_issues_yields_empty_untracked(self):
|
|
267
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
268
|
+
export_map = {(_SHARED_REPO, 1): _ISSUE_A}
|
|
269
|
+
open_rows = {_SHARED_REPO: []}
|
|
270
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
271
|
+
self.assertEqual(rc, 0)
|
|
272
|
+
self.assertEqual(out["untracked"], [])
|
|
273
|
+
|
|
274
|
+
def test_schema_stays_1_with_untracked(self):
|
|
275
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
276
|
+
open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
|
|
277
|
+
export_map = {(_SHARED_REPO, 1): _ISSUE_A}
|
|
278
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
279
|
+
self.assertEqual(out["schema"], 1)
|
|
280
|
+
|
|
281
|
+
def test_output_serializable_with_untracked(self):
|
|
282
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
283
|
+
open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_C]}
|
|
284
|
+
export_map = {(_SHARED_REPO, 1): _ISSUE_A}
|
|
285
|
+
rc, out = self._run_with_mocks(tracks, export_map, open_rows)
|
|
286
|
+
json.dumps(out) # must not raise
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ExportCommandGateTest(unittest.TestCase):
|
|
290
|
+
def test_requires_json_flag(self):
|
|
291
|
+
self.assertEqual(export_cmd.run([]), 2)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
unittest.main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for frontmatter parser/writer."""
|
|
2
|
+
import unittest
|
|
3
|
+
import tempfile
|
|
4
|
+
import sys
|
|
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.frontmatter import parse_file, write_file
|
|
11
|
+
|
|
12
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FrontmatterTest(unittest.TestCase):
|
|
16
|
+
def test_parse_file_with_frontmatter(self):
|
|
17
|
+
meta, body = parse_file(FIXTURES / "track_with_frontmatter.md")
|
|
18
|
+
self.assertEqual(meta["track"], "tabletop")
|
|
19
|
+
self.assertEqual(meta["github"]["issues"], [4254, 4127])
|
|
20
|
+
self.assertIn("Body content.", body)
|
|
21
|
+
|
|
22
|
+
def test_parse_file_without_frontmatter_returns_empty_meta(self):
|
|
23
|
+
meta, body = parse_file(FIXTURES / "track_without_frontmatter.md")
|
|
24
|
+
self.assertEqual(meta, {})
|
|
25
|
+
self.assertIn("# Some plan", body)
|
|
26
|
+
|
|
27
|
+
def test_write_then_parse_roundtrip(self):
|
|
28
|
+
meta = {
|
|
29
|
+
"track": "test",
|
|
30
|
+
"status": "active",
|
|
31
|
+
"github": {"repo": "org/repo", "issues": [42]},
|
|
32
|
+
}
|
|
33
|
+
body = "\n# Body\n\nProse.\n"
|
|
34
|
+
with tempfile.TemporaryDirectory() as d:
|
|
35
|
+
path = Path(d) / "t.md"
|
|
36
|
+
write_file(path, meta, body)
|
|
37
|
+
m2, b2 = parse_file(path)
|
|
38
|
+
self.assertEqual(m2, meta)
|
|
39
|
+
self.assertEqual(b2, body)
|
|
40
|
+
|
|
41
|
+
def test_write_with_empty_meta_writes_body_only(self):
|
|
42
|
+
body = "# Title\n\nProse.\n"
|
|
43
|
+
with tempfile.TemporaryDirectory() as d:
|
|
44
|
+
path = Path(d) / "t.md"
|
|
45
|
+
write_file(path, {}, body)
|
|
46
|
+
m, b = parse_file(path)
|
|
47
|
+
self.assertEqual(m, {})
|
|
48
|
+
self.assertEqual(b, body)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for git_state pure functions."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.git_state import (
|
|
10
|
+
gap_seconds_to_label, parse_iso_timestamp,
|
|
11
|
+
branch_in_progress,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GapLabelTest(unittest.TestCase):
|
|
16
|
+
def test_minutes(self):
|
|
17
|
+
self.assertEqual(gap_seconds_to_label(30 * 60), "30m ago")
|
|
18
|
+
|
|
19
|
+
def test_one_hour(self):
|
|
20
|
+
self.assertEqual(gap_seconds_to_label(3600), "1h ago")
|
|
21
|
+
|
|
22
|
+
def test_six_hours(self):
|
|
23
|
+
self.assertEqual(gap_seconds_to_label(6 * 3600), "6h ago")
|
|
24
|
+
|
|
25
|
+
def test_one_day(self):
|
|
26
|
+
self.assertEqual(gap_seconds_to_label(86400), "1d ago")
|
|
27
|
+
|
|
28
|
+
def test_multi_days(self):
|
|
29
|
+
self.assertEqual(gap_seconds_to_label(5 * 86400 + 3600), "5d ago")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParseTimestampTest(unittest.TestCase):
|
|
33
|
+
def test_iso_with_hour(self):
|
|
34
|
+
dt = parse_iso_timestamp("2026-04-23T22:14")
|
|
35
|
+
self.assertEqual(dt.hour, 22)
|
|
36
|
+
|
|
37
|
+
def test_iso_date_only(self):
|
|
38
|
+
dt = parse_iso_timestamp("2026-04-23")
|
|
39
|
+
self.assertEqual(dt.year, 2026)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BranchInProgressTest(unittest.TestCase):
|
|
43
|
+
def test_returns_false_when_repo_path_missing(self):
|
|
44
|
+
self.assertFalse(branch_in_progress("any-branch", None))
|
|
45
|
+
|
|
46
|
+
def test_returns_false_when_path_doesnt_exist(self):
|
|
47
|
+
self.assertFalse(branch_in_progress("any-branch", Path("/nonexistent")))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for path-level git helpers (mock subprocess; offline)."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
11
|
+
|
|
12
|
+
from lib import git_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PathLastCommitDateTest(unittest.TestCase):
|
|
16
|
+
def test_returns_none_when_path_missing(self):
|
|
17
|
+
self.assertIsNone(git_state.path_last_commit_date("x", None))
|
|
18
|
+
|
|
19
|
+
def test_parses_iso(self):
|
|
20
|
+
fake = SimpleNamespace(returncode=0, stdout="2026-04-02T13:05:11-05:00\n")
|
|
21
|
+
with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
|
|
22
|
+
mock.patch("lib.git_state.Path.exists", return_value=True):
|
|
23
|
+
dt = git_state.path_last_commit_date("docs/x.md", Path("/repo"))
|
|
24
|
+
self.assertIsInstance(dt, datetime)
|
|
25
|
+
self.assertEqual(dt.date(), date(2026, 4, 2))
|
|
26
|
+
|
|
27
|
+
def test_empty_output_is_none(self):
|
|
28
|
+
fake = SimpleNamespace(returncode=0, stdout="")
|
|
29
|
+
with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
|
|
30
|
+
mock.patch("lib.git_state.Path.exists", return_value=True):
|
|
31
|
+
self.assertIsNone(git_state.path_last_commit_date("docs/x.md", Path("/repo")))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PathCommittedSinceTest(unittest.TestCase):
|
|
35
|
+
def test_true_when_log_nonempty(self):
|
|
36
|
+
fake = SimpleNamespace(returncode=0, stdout="abc123\n")
|
|
37
|
+
with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
|
|
38
|
+
mock.patch("lib.git_state.Path.exists", return_value=True):
|
|
39
|
+
self.assertTrue(
|
|
40
|
+
git_state.path_committed_since("src/a.ts", date(2026, 3, 1), Path("/repo")))
|
|
41
|
+
|
|
42
|
+
def test_false_when_empty(self):
|
|
43
|
+
fake = SimpleNamespace(returncode=0, stdout="")
|
|
44
|
+
with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
|
|
45
|
+
mock.patch("lib.git_state.Path.exists", return_value=True):
|
|
46
|
+
self.assertFalse(
|
|
47
|
+
git_state.path_committed_since("src/a.ts", date(2026, 3, 1), Path("/repo")))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|