@stylusnexus/work-plan 2026.6.11-2 → 2026.6.13
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 +8 -3
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/plan_status.py +76 -9
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/lib/export_model.py +21 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/manifest.py +10 -0
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_export.py +40 -0
- package/skills/work-plan/tests/test_export_command.py +19 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/work_plan.py +14 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for the plan-status staleness clock (#164).
|
|
2
|
+
|
|
3
|
+
The clock keys off a plan's DECLARED manifest files (which get committed) — not
|
|
4
|
+
the plan doc's own git date, which is null because plan docs are gitignored.
|
|
5
|
+
All git is mocked; these run offline.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
14
|
+
|
|
15
|
+
from lib import git_state
|
|
16
|
+
from lib import manifest
|
|
17
|
+
from lib import verdict as verdict_mod
|
|
18
|
+
from commands import plan_status
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestPathsLastCommitDate(unittest.TestCase):
|
|
22
|
+
def test_returns_max_date_over_paths(self):
|
|
23
|
+
proc = mock.Mock(returncode=0, stdout="2026-06-10T12:00:00+00:00")
|
|
24
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
25
|
+
mock.patch.object(git_state, "_git", return_value=proc):
|
|
26
|
+
got = git_state.paths_last_commit_date(
|
|
27
|
+
["a.py", "b.py"], Path("/repo"))
|
|
28
|
+
self.assertEqual(got, datetime(2026, 6, 10, 12, 0, 0))
|
|
29
|
+
|
|
30
|
+
def test_empty_paths_is_none(self):
|
|
31
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
32
|
+
mock.patch.object(git_state, "_git") as g:
|
|
33
|
+
self.assertIsNone(git_state.paths_last_commit_date([], Path("/repo")))
|
|
34
|
+
g.assert_not_called()
|
|
35
|
+
|
|
36
|
+
def test_empty_stdout_is_none(self):
|
|
37
|
+
proc = mock.Mock(returncode=0, stdout="")
|
|
38
|
+
with mock.patch.object(Path, "exists", return_value=True), \
|
|
39
|
+
mock.patch.object(git_state, "_git", return_value=proc):
|
|
40
|
+
self.assertIsNone(
|
|
41
|
+
git_state.paths_last_commit_date(["a.py"], Path("/repo")))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestStallDaysConstant(unittest.TestCase):
|
|
45
|
+
def test_default_is_14(self):
|
|
46
|
+
self.assertEqual(verdict_mod.STALL_DAYS, 14)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestUncheckedCheckboxLabels(unittest.TestCase):
|
|
50
|
+
def test_captures_unticked_labels_in_order(self):
|
|
51
|
+
text = (
|
|
52
|
+
"- [x] Phase 1 — git helper\n"
|
|
53
|
+
"- [x] Phase 2 — manifest\n"
|
|
54
|
+
"- [ ] Phase 4 — tests\n"
|
|
55
|
+
"- [ ] Phase 5 — docs\n"
|
|
56
|
+
)
|
|
57
|
+
self.assertEqual(
|
|
58
|
+
manifest.unchecked_checkbox_labels(text),
|
|
59
|
+
["Phase 4 — tests", "Phase 5 — docs"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def test_cap_limits_results(self):
|
|
63
|
+
text = "\n".join(f"- [ ] item {i}" for i in range(20))
|
|
64
|
+
got = manifest.unchecked_checkbox_labels(text)
|
|
65
|
+
self.assertEqual(len(got), 10)
|
|
66
|
+
self.assertEqual(got[0], "item 0")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _FakePath:
|
|
70
|
+
def __init__(self, name):
|
|
71
|
+
self.name = name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Doc:
|
|
75
|
+
@classmethod
|
|
76
|
+
def make(cls, rel="plans/p.md", kind="plan", name="2026-05-01-p.md"):
|
|
77
|
+
d = cls.__new__(cls)
|
|
78
|
+
d.rel = rel
|
|
79
|
+
d.kind = kind
|
|
80
|
+
d.path = _FakePath(name)
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decl(path):
|
|
85
|
+
return manifest.DeclaredPath(kind="create", path=path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestEvaluateStaleness(unittest.TestCase):
|
|
89
|
+
"""The staleness ladder fires only for partial verdicts and keys off the
|
|
90
|
+
manifest files' commit date, not the plan doc's own (gitignored) date."""
|
|
91
|
+
|
|
92
|
+
def setUp(self):
|
|
93
|
+
self.today = date(2026, 6, 12)
|
|
94
|
+
self.partial = verdict_mod.Verdict("partial", "\U0001f7e1", "files")
|
|
95
|
+
self.decls = [_decl("src/a.py"), _decl("src/b.py")]
|
|
96
|
+
|
|
97
|
+
def _evaluate(self, manifest_date, on_disk, verdict=None, text="body"):
|
|
98
|
+
"""Run _evaluate with manifest.* / git_state.* / classify mocked.
|
|
99
|
+
|
|
100
|
+
manifest_date: what paths_last_commit_date returns.
|
|
101
|
+
on_disk: which declared paths _declared_paths_on_disk reports present.
|
|
102
|
+
"""
|
|
103
|
+
v = verdict or self.partial
|
|
104
|
+
doc = _Doc.make()
|
|
105
|
+
with mock.patch.object(plan_status, "_read", return_value=text), \
|
|
106
|
+
mock.patch.object(manifest, "parse_declared_paths", return_value=self.decls), \
|
|
107
|
+
mock.patch.object(manifest, "plan_date_from_filename", return_value=None), \
|
|
108
|
+
mock.patch.object(manifest, "score_manifest",
|
|
109
|
+
return_value=manifest.ManifestScore(2, 1, {})), \
|
|
110
|
+
mock.patch.object(manifest, "count_checkboxes", return_value=(1, 4)), \
|
|
111
|
+
mock.patch.object(manifest, "out_of_tree_ratio", return_value=0.0), \
|
|
112
|
+
mock.patch.object(manifest, "unchecked_checkbox_labels",
|
|
113
|
+
return_value=["do x"]), \
|
|
114
|
+
mock.patch.object(plan_status, "_declared_paths_on_disk",
|
|
115
|
+
return_value=on_disk), \
|
|
116
|
+
mock.patch.object(git_state, "path_last_commit_date", return_value=None), \
|
|
117
|
+
mock.patch.object(git_state, "paths_last_commit_date",
|
|
118
|
+
return_value=manifest_date), \
|
|
119
|
+
mock.patch.object(verdict_mod, "classify", return_value=v):
|
|
120
|
+
return plan_status._evaluate(doc, Path("/repo"), self.today, 60, 14)
|
|
121
|
+
|
|
122
|
+
def test_partial_cold_is_stalled(self):
|
|
123
|
+
cold = datetime(2026, 5, 1, 12, 0, 0) # 42 days before today
|
|
124
|
+
row = self._evaluate(cold, ["src/a.py", "src/b.py"])
|
|
125
|
+
self.assertTrue(row["stalled"])
|
|
126
|
+
self.assertEqual(row["manifest_last_touched"], "2026-05-01")
|
|
127
|
+
|
|
128
|
+
def test_partial_warm_is_not_stalled(self):
|
|
129
|
+
warm = datetime(2026, 6, 10, 12, 0, 0) # 2 days before today
|
|
130
|
+
row = self._evaluate(warm, ["src/a.py"])
|
|
131
|
+
self.assertFalse(row["stalled"])
|
|
132
|
+
|
|
133
|
+
def test_partial_no_files_on_disk_is_not_stalled(self):
|
|
134
|
+
row = self._evaluate(None, [])
|
|
135
|
+
self.assertFalse(row["stalled"])
|
|
136
|
+
self.assertIsNone(row["manifest_last_touched"])
|
|
137
|
+
|
|
138
|
+
def test_doc_uncommitted_but_manifest_committed_is_stalled(self):
|
|
139
|
+
# path_last_commit_date (doc) is None, but manifest committed 42d ago.
|
|
140
|
+
cold = datetime(2026, 5, 1, 12, 0, 0)
|
|
141
|
+
row = self._evaluate(cold, ["src/a.py"])
|
|
142
|
+
self.assertTrue(row["stalled"])
|
|
143
|
+
self.assertIsNone(row["last_touched"]) # doc date stays None
|
|
144
|
+
|
|
145
|
+
def test_present_but_never_committed_is_stalled(self):
|
|
146
|
+
# files exist on disk but manifest date is None -> never committed
|
|
147
|
+
row = self._evaluate(None, ["src/a.py"])
|
|
148
|
+
self.assertTrue(row["stalled"])
|
|
149
|
+
|
|
150
|
+
def test_emits_unchecked_items_and_stall_days(self):
|
|
151
|
+
row = self._evaluate(datetime(2026, 6, 10), ["src/a.py"])
|
|
152
|
+
self.assertEqual(row["unchecked_items"], ["do x"])
|
|
153
|
+
self.assertEqual(row["stall_days"], 14)
|
|
154
|
+
|
|
155
|
+
def test_non_partial_is_never_stalled(self):
|
|
156
|
+
shipped = verdict_mod.Verdict("shipped", "✅", "files")
|
|
157
|
+
row = self._evaluate(None, [], verdict=shipped)
|
|
158
|
+
self.assertFalse(row["stalled"])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestResolveStallDays(unittest.TestCase):
|
|
162
|
+
def test_known_flag_set_includes_stall_days(self):
|
|
163
|
+
self.assertIn("--stall-days", plan_status.KNOWN)
|
|
164
|
+
|
|
165
|
+
def test_flag_beats_config_beats_default(self):
|
|
166
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
167
|
+
return_value={"stall_days": 30}):
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
plan_status._resolve_stall_days({"--stall-days": "45"}), 45)
|
|
170
|
+
|
|
171
|
+
def test_config_beats_default(self):
|
|
172
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
173
|
+
return_value={"stall_days": 30}):
|
|
174
|
+
self.assertEqual(plan_status._resolve_stall_days({}), 30)
|
|
175
|
+
|
|
176
|
+
def test_default_when_unset(self):
|
|
177
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
178
|
+
return_value={}):
|
|
179
|
+
self.assertEqual(plan_status._resolve_stall_days({}), 14)
|
|
180
|
+
|
|
181
|
+
def test_non_integer_flag_falls_through(self):
|
|
182
|
+
with mock.patch.object(plan_status.config_mod, "load_config",
|
|
183
|
+
return_value={}):
|
|
184
|
+
self.assertEqual(
|
|
185
|
+
plan_status._resolve_stall_days({"--stall-days": "abc"}), 14)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TestDeclaredPathsOnDiskGuards(unittest.TestCase):
|
|
189
|
+
"""A junk declared path ('/'), a directory, or an out-of-tree '../x' must be
|
|
190
|
+
excluded — otherwise they poison `git log -- <paths>` and falsely stall an
|
|
191
|
+
actively-built plan (regression from the smoke test for #164)."""
|
|
192
|
+
|
|
193
|
+
def test_excludes_root_slash_dirs_and_escapes_keeps_real_files(self):
|
|
194
|
+
import tempfile, os
|
|
195
|
+
from lib.manifest import DeclaredPath
|
|
196
|
+
with tempfile.TemporaryDirectory() as td:
|
|
197
|
+
root = Path(td)
|
|
198
|
+
(root / "src").mkdir()
|
|
199
|
+
real = "src/a.py"
|
|
200
|
+
(root / real).write_text("x")
|
|
201
|
+
# a sibling file outside the repo root
|
|
202
|
+
outside = Path(td).parent / "escape_probe_164.py"
|
|
203
|
+
try:
|
|
204
|
+
outside.write_text("x")
|
|
205
|
+
decls = [
|
|
206
|
+
DeclaredPath(kind="create", path=real), # real file -> kept
|
|
207
|
+
DeclaredPath(kind="create", path="/"), # resolves to FS root dir -> dropped
|
|
208
|
+
DeclaredPath(kind="create", path="src"), # a directory -> dropped
|
|
209
|
+
DeclaredPath(kind="modify", path=f"../{outside.name}"), # out-of-tree -> dropped
|
|
210
|
+
]
|
|
211
|
+
got = plan_status._declared_paths_on_disk(decls, root)
|
|
212
|
+
self.assertEqual(got, [real])
|
|
213
|
+
finally:
|
|
214
|
+
if outside.exists():
|
|
215
|
+
outside.unlink()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
unittest.main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Cross-repo duplicate-slug isolation in reconcile (#255).
|
|
2
|
+
|
|
3
|
+
Identical track slugs in DIFFERENT repos are explicitly supported. Reconcile's
|
|
4
|
+
in-flight state must be keyed by a per-track identity (repo, path), not by slug
|
|
5
|
+
— otherwise a later same-slug track's fetch overwrites the earlier one's, and
|
|
6
|
+
under `--all --yes` issues from one repo get written into the same-named track
|
|
7
|
+
in ANOTHER repo (membership corruption).
|
|
8
|
+
|
|
9
|
+
All gh calls are mocked; tests run offline. The fake `gh` here is REPO-AWARE
|
|
10
|
+
(unlike the shared move harness) so two same-slug tracks in different repos can
|
|
11
|
+
return different labeled issues.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
from unittest.mock import MagicMock, patch
|
|
19
|
+
|
|
20
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
22
|
+
|
|
23
|
+
from commands import reconcile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _track(*, slug, repo, issues=None):
|
|
27
|
+
# Path embeds the repo so two same-slug tracks have distinct file paths,
|
|
28
|
+
# mirroring how discover_tracks lays out per-repo note dirs.
|
|
29
|
+
safe_repo = repo.replace("/", "_")
|
|
30
|
+
return SimpleNamespace(
|
|
31
|
+
name=slug,
|
|
32
|
+
path=Path(f"/tmp/fake/{safe_repo}/{slug}.md"),
|
|
33
|
+
body="# fake",
|
|
34
|
+
meta={"track": slug, "status": "active",
|
|
35
|
+
"github": {"repo": repo, "issues": list(issues or [])}},
|
|
36
|
+
has_frontmatter=True,
|
|
37
|
+
repo=repo,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RepoAwareHarness:
|
|
42
|
+
"""Drives reconcile --all where labeled issues depend on BOTH repo and label.
|
|
43
|
+
|
|
44
|
+
`labeled` maps (repo, label) -> list of issue dicts that
|
|
45
|
+
`gh issue/pr list --repo <repo> --label <label>` should return.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, tracks, labeled):
|
|
49
|
+
self.tracks = tracks
|
|
50
|
+
self.labeled = labeled
|
|
51
|
+
self.writes = [] # (path_str, issues) per write_file call
|
|
52
|
+
|
|
53
|
+
def _fake_run(self, argv, *a, **kw):
|
|
54
|
+
out = []
|
|
55
|
+
if "--label" in argv and argv[1] == "issue": # count issues once, not PRs
|
|
56
|
+
repo = argv[argv.index("--repo") + 1]
|
|
57
|
+
lab = argv[argv.index("--label") + 1]
|
|
58
|
+
out = self.labeled.get((repo, lab), [])
|
|
59
|
+
return MagicMock(returncode=0, stdout=json.dumps(out), stderr="")
|
|
60
|
+
|
|
61
|
+
def _fake_write(self, path, meta, body):
|
|
62
|
+
self.writes.append((str(path), list(meta.get("github", {}).get("issues") or [])))
|
|
63
|
+
|
|
64
|
+
def run(self, extra_args=None):
|
|
65
|
+
cfg = {"notes_root": "/tmp/n"}
|
|
66
|
+
with patch("commands.reconcile.subprocess.run", side_effect=self._fake_run), \
|
|
67
|
+
patch("commands.reconcile.load_config", return_value=cfg), \
|
|
68
|
+
patch("commands.reconcile.discover_tracks", return_value=self.tracks), \
|
|
69
|
+
patch("commands.reconcile.needs_confirm", return_value=False), \
|
|
70
|
+
patch("commands.reconcile.write_file", side_effect=self._fake_write), \
|
|
71
|
+
patch("commands.reconcile.prompt_input", return_value="y"):
|
|
72
|
+
rc = reconcile.run(["--all"] + (extra_args or []))
|
|
73
|
+
return rc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DupSlugCrossRepoTest(unittest.TestCase):
|
|
77
|
+
def test_adds_land_in_the_correct_repo_track(self):
|
|
78
|
+
"""Two tracks share slug 'core' across repos o/a and o/b. Each repo
|
|
79
|
+
labels a DIFFERENT issue. Under --all --yes, each issue must land in
|
|
80
|
+
its OWN repo's track — never bleed into the same-named sibling."""
|
|
81
|
+
a = _track(slug="core", repo="o/a", issues=[])
|
|
82
|
+
b = _track(slug="core", repo="o/b", issues=[])
|
|
83
|
+
labeled = {
|
|
84
|
+
("o/a", "track/core"): [{"number": 11, "title": "a-issue", "state": "OPEN"}],
|
|
85
|
+
("o/b", "track/core"): [{"number": 22, "title": "b-issue", "state": "OPEN"}],
|
|
86
|
+
}
|
|
87
|
+
h = _RepoAwareHarness([a, b], labeled)
|
|
88
|
+
rc = h.run(extra_args=["--yes"])
|
|
89
|
+
self.assertEqual(rc, 0)
|
|
90
|
+
writes = dict(h.writes)
|
|
91
|
+
# Key off each track's real Path string (backslashes on Windows), not a
|
|
92
|
+
# hardcoded POSIX literal — the collision is the slug, not the path.
|
|
93
|
+
self.assertEqual(writes[str(a.path)], [11])
|
|
94
|
+
self.assertEqual(writes[str(b.path)], [22])
|
|
95
|
+
|
|
96
|
+
def test_failed_fetch_does_not_corrupt_same_slug_sibling(self):
|
|
97
|
+
"""If repo o/a's fetch is intact but o/b's is independent, the second
|
|
98
|
+
track's results must not overwrite the first's. Here o/a adds #11 and
|
|
99
|
+
o/b adds #22; pre-fix, the shared 'core' key meant the second fetch
|
|
100
|
+
clobbered the first and #11 was lost / mis-routed."""
|
|
101
|
+
a = _track(slug="core", repo="o/a", issues=[11]) # already has #11
|
|
102
|
+
b = _track(slug="core", repo="o/b", issues=[])
|
|
103
|
+
labeled = {
|
|
104
|
+
# o/a still labels #11 (no change → no write expected for a)
|
|
105
|
+
("o/a", "track/core"): [{"number": 11, "title": "a", "state": "OPEN"}],
|
|
106
|
+
# o/b newly labels #99
|
|
107
|
+
("o/b", "track/core"): [{"number": 99, "title": "b", "state": "OPEN"}],
|
|
108
|
+
}
|
|
109
|
+
h = _RepoAwareHarness([a, b], labeled)
|
|
110
|
+
rc = h.run(extra_args=["--yes"])
|
|
111
|
+
self.assertEqual(rc, 0)
|
|
112
|
+
writes = dict(h.writes)
|
|
113
|
+
# o/a unchanged → not written. o/b gains #99 only — NOT #11.
|
|
114
|
+
self.assertNotIn(str(a.path), writes)
|
|
115
|
+
self.assertEqual(writes[str(b.path)], [99])
|
|
116
|
+
|
|
117
|
+
def test_no_cross_repo_move_between_same_slug_tracks(self):
|
|
118
|
+
"""A move only fires within ONE repo. #50 sits in o/a's 'core'
|
|
119
|
+
frontmatter and is labeled for o/b's 'core' — different repos, so it
|
|
120
|
+
must stay a FLAG on o/a, not move across the repo boundary."""
|
|
121
|
+
a = _track(slug="core", repo="o/a", issues=[50])
|
|
122
|
+
b = _track(slug="core", repo="o/b", issues=[])
|
|
123
|
+
labeled = {
|
|
124
|
+
("o/a", "track/core"): [], # #50 lost its label in o/a
|
|
125
|
+
("o/b", "track/core"): [{"number": 50, "title": "x", "state": "OPEN"}],
|
|
126
|
+
}
|
|
127
|
+
h = _RepoAwareHarness([a, b], labeled)
|
|
128
|
+
rc = h.run(extra_args=["--yes"])
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
writes = dict(h.writes)
|
|
131
|
+
# o/a keeps #50 (no same-repo move target) → not rewritten.
|
|
132
|
+
self.assertNotIn(str(a.path), writes)
|
|
133
|
+
# o/b ADDs #50 because it now carries o/b's label — legitimate, in-repo.
|
|
134
|
+
self.assertEqual(writes.get(str(b.path)), [50])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
unittest.main()
|
|
@@ -160,6 +160,81 @@ class CanonicalRederiveTest(unittest.TestCase):
|
|
|
160
160
|
self.assertIn("## Notes\n\nkeep me", mw.call_args[0][2])
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
class PartialFetchTest(unittest.TestCase):
|
|
164
|
+
"""A degraded GitHub fetch must never overwrite valid rows with
|
|
165
|
+
'(not fetched)'. The track is skipped, left untouched, and the run exits
|
|
166
|
+
nonzero so --yes / hygiene callers see the degradation (#256)."""
|
|
167
|
+
|
|
168
|
+
def test_partial_fetch_skips_track_and_preserves_rows(self):
|
|
169
|
+
"""One of several frontmatter issues missing → no write, rc=1, the
|
|
170
|
+
existing table is left exactly as it was."""
|
|
171
|
+
existing = [_gh(1, "first"), _gh(2, "second")]
|
|
172
|
+
track = _track(name="t", repo="o/r", issues=[1, 2, 3],
|
|
173
|
+
body=_canon_body(existing + [_gh(3, "third")]))
|
|
174
|
+
original_body = track.body
|
|
175
|
+
# #3 fails to come back from the fetch.
|
|
176
|
+
rc, mw, out = _drive(track, existing, ["t", "--yes"])
|
|
177
|
+
self.assertEqual(rc, 1)
|
|
178
|
+
mw.assert_not_called()
|
|
179
|
+
self.assertEqual(track.body, original_body)
|
|
180
|
+
self.assertNotIn("(not fetched)", out)
|
|
181
|
+
self.assertIn("#3", out)
|
|
182
|
+
self.assertNotIn("All tracks in sync.", out)
|
|
183
|
+
|
|
184
|
+
def test_total_fetch_failure_skips_track(self):
|
|
185
|
+
"""Fetch returns nothing (GitHub unreachable) → track skipped, rc=1,
|
|
186
|
+
no write, table untouched."""
|
|
187
|
+
existing = [_gh(1, "first"), _gh(2, "second")]
|
|
188
|
+
track = _track(name="t", repo="o/r", issues=[1, 2],
|
|
189
|
+
body=_canon_body(existing))
|
|
190
|
+
rc, mw, out = _drive(track, [], ["t", "--yes"])
|
|
191
|
+
self.assertEqual(rc, 1)
|
|
192
|
+
mw.assert_not_called()
|
|
193
|
+
self.assertIn("no issues", out)
|
|
194
|
+
|
|
195
|
+
def test_healthy_track_still_refreshes_alongside_degraded(self):
|
|
196
|
+
"""In an --all batch, a complete-fetch track writes normally while a
|
|
197
|
+
degraded track is skipped; the run still exits nonzero overall."""
|
|
198
|
+
good_existing = [_gh(1, "first", "OPEN")]
|
|
199
|
+
good = _track(name="good", repo="o/r", issues=[1],
|
|
200
|
+
body=_canon_body(good_existing))
|
|
201
|
+
bad = _track(name="bad", repo="o/r", issues=[5, 6],
|
|
202
|
+
body=_canon_body([_gh(5, "fifth"), _gh(6, "sixth")]))
|
|
203
|
+
|
|
204
|
+
# good: #1 fetched (and flipped to CLOSED so there's a write); bad: #6 missing.
|
|
205
|
+
fetched = [_gh(1, "first", "CLOSED"), _gh(5, "fifth")]
|
|
206
|
+
cfg = {"notes_root": "/tmp/fake"}
|
|
207
|
+
with patch("commands.refresh_md.load_config", return_value=cfg), \
|
|
208
|
+
patch("commands.refresh_md.discover_tracks", return_value=[good, bad]), \
|
|
209
|
+
patch("commands.refresh_md.fetch_issues", return_value=fetched), \
|
|
210
|
+
patch("commands.refresh_md.write_file") as mw:
|
|
211
|
+
buf = io.StringIO()
|
|
212
|
+
with redirect_stdout(buf):
|
|
213
|
+
rc = refresh_md.run(["--all", "--yes"])
|
|
214
|
+
out = buf.getvalue()
|
|
215
|
+
self.assertEqual(rc, 1)
|
|
216
|
+
# Only the healthy track is written.
|
|
217
|
+
self.assertEqual(mw.call_count, 1)
|
|
218
|
+
written_path = mw.call_args[0][0]
|
|
219
|
+
self.assertEqual(written_path.name, "good.md")
|
|
220
|
+
self.assertIn("#6", out) # degraded track's missing issue is reported
|
|
221
|
+
|
|
222
|
+
def test_table_only_number_absent_from_frontmatter_does_not_gate(self):
|
|
223
|
+
"""A number that appears in the body table but NOT in frontmatter
|
|
224
|
+
doesn't feed the rebuild, so a fetch miss on it is harmless — the track
|
|
225
|
+
still refreshes (rc=0)."""
|
|
226
|
+
# Frontmatter membership is [1, 2]; the body also references #99, which
|
|
227
|
+
# is not in frontmatter. #99 is not fetched, but must not block.
|
|
228
|
+
existing = [_gh(1, "first", "OPEN"), _gh(2, "second")]
|
|
229
|
+
body = _canon_body(existing) + "\nSee also #99 for context.\n"
|
|
230
|
+
track = _track(name="t", repo="o/r", issues=[1, 2], body=body)
|
|
231
|
+
rc, mw, out = _drive(track, [_gh(1, "first", "CLOSED"), _gh(2, "second")],
|
|
232
|
+
["t", "--yes"])
|
|
233
|
+
self.assertEqual(rc, 0)
|
|
234
|
+
mw.assert_called_once()
|
|
235
|
+
self.assertNotIn("(not fetched)", mw.call_args[0][2])
|
|
236
|
+
|
|
237
|
+
|
|
163
238
|
class NarrativeTableTest(unittest.TestCase):
|
|
164
239
|
"""Tracks with NO canonical marker keep the conservative in-place behavior."""
|
|
165
240
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for the non-interactive remove-repo command (#290).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Removing an existing key → yq called with del(.repos.<key>); rc 0.
|
|
5
|
+
- Missing key (not in config) → rc 1, yq NOT called.
|
|
6
|
+
- Invalid key format → rc 2, yq NOT called.
|
|
7
|
+
- No positional key → rc 2.
|
|
8
|
+
"""
|
|
9
|
+
import io
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from contextlib import redirect_stdout
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from unittest.mock import patch, MagicMock
|
|
15
|
+
|
|
16
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
18
|
+
|
|
19
|
+
from commands import remove_repo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
|
|
23
|
+
return {"notes_root": notes_root, "repos": repos or {}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _drive(args, *, existing_repos=None):
|
|
27
|
+
cfg = _make_cfg(repos=existing_repos or {})
|
|
28
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
29
|
+
with patch("commands.remove_repo.load_config", return_value=cfg), \
|
|
30
|
+
patch("commands.remove_repo.subprocess.run", return_value=mock_proc) as msub, \
|
|
31
|
+
patch("pathlib.Path.exists", return_value=False):
|
|
32
|
+
buf = io.StringIO()
|
|
33
|
+
with redirect_stdout(buf):
|
|
34
|
+
rc = remove_repo.run(args)
|
|
35
|
+
return rc, msub, buf.getvalue()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RemoveRepoTest(unittest.TestCase):
|
|
39
|
+
|
|
40
|
+
def test_removes_existing_key(self):
|
|
41
|
+
"""Existing key → yq del(.repos.<key>) called; rc 0; '✓ Removed' printed."""
|
|
42
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/some/path"}}
|
|
43
|
+
rc, msub, out = _drive(["mykey"], existing_repos=existing)
|
|
44
|
+
self.assertEqual(rc, 0)
|
|
45
|
+
msub.assert_called_once()
|
|
46
|
+
yq_args = msub.call_args[0][0]
|
|
47
|
+
self.assertEqual(yq_args[0], "yq")
|
|
48
|
+
self.assertEqual(yq_args[1], "-i")
|
|
49
|
+
self.assertEqual(yq_args[2], "del(.repos.mykey)")
|
|
50
|
+
self.assertIn("✓ Removed", out)
|
|
51
|
+
# Config-only note surfaces the untouched local clone.
|
|
52
|
+
self.assertIn("config-only", out)
|
|
53
|
+
|
|
54
|
+
def test_missing_key_returns_rc1(self):
|
|
55
|
+
"""Key not in config.repos → rc 1, yq NOT called."""
|
|
56
|
+
existing = {"otherkey": {"github": "org/other"}}
|
|
57
|
+
rc, msub, out = _drive(["mykey"], existing_repos=existing)
|
|
58
|
+
self.assertEqual(rc, 1)
|
|
59
|
+
msub.assert_not_called()
|
|
60
|
+
self.assertIn("not found", out)
|
|
61
|
+
|
|
62
|
+
def test_invalid_key_format_returns_rc2(self):
|
|
63
|
+
"""Uppercase key → rc 2, yq NOT called (validated before load)."""
|
|
64
|
+
rc, msub, out = _drive(["MyKey"], existing_repos={"MyKey": {}})
|
|
65
|
+
self.assertEqual(rc, 2)
|
|
66
|
+
msub.assert_not_called()
|
|
67
|
+
self.assertIn("not a valid key", out)
|
|
68
|
+
|
|
69
|
+
def test_no_key_returns_rc2(self):
|
|
70
|
+
"""No positional key → rc 2, yq NOT called."""
|
|
71
|
+
rc, msub, out = _drive([], existing_repos={})
|
|
72
|
+
self.assertEqual(rc, 2)
|
|
73
|
+
msub.assert_not_called()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
@@ -38,6 +38,7 @@ SUBCOMMANDS = {
|
|
|
38
38
|
"list": "commands.list_cmd",
|
|
39
39
|
"init": "commands.init",
|
|
40
40
|
"init-repo": "commands.init_repo",
|
|
41
|
+
"remove-repo": "commands.remove_repo",
|
|
41
42
|
"suggest-priorities": "commands.suggest_priorities",
|
|
42
43
|
"group": "commands.group",
|
|
43
44
|
"auto-triage": "commands.auto_triage",
|
|
@@ -51,6 +52,7 @@ SUBCOMMANDS = {
|
|
|
51
52
|
"plan-status": "commands.plan_status",
|
|
52
53
|
"--plan-status": "commands.plan_status", # flag-style alias
|
|
53
54
|
"export": "commands.export",
|
|
55
|
+
"list-open-issues": "commands.list_open_issues",
|
|
54
56
|
"set": "commands.set_field",
|
|
55
57
|
"new-track": "commands.new_track",
|
|
56
58
|
"rename-track": "commands.rename_track",
|
|
@@ -101,10 +103,14 @@ DESCRIPTIONS = [
|
|
|
101
103
|
"Add frontmatter to an existing track .md file.",
|
|
102
104
|
"After moving/creating a new .md file in Project Notes/<repo>/ that has no frontmatter.",
|
|
103
105
|
"/work-plan init '<notes_root>/<repo-key>/foo.md'"),
|
|
104
|
-
("init-repo", "<key> --github=<org/repo> [--local=<path>]",
|
|
105
|
-
"Bootstrap a new repo: create <notes_root>/<key>/archive/{shipped,abandoned}/ and add the repo block to your config.",
|
|
106
|
-
"When you start tracking a new GitHub repo. Replaces the old 'copy the example folder' setup.",
|
|
106
|
+
("init-repo", "<key> --github=<org/repo> [--local=<path>] [--update [--clear-local]]",
|
|
107
|
+
"Bootstrap a new repo: create <notes_root>/<key>/archive/{shipped,abandoned}/ and add the repo block to your config. With --update on an existing key, change its local/github; --update --clear-local drops the saved local path (keeps github + other fields). --clear-local and --local are mutually exclusive.",
|
|
108
|
+
"When you start tracking a new GitHub repo. Replaces the old 'copy the example folder' setup. Use --update --clear-local to forget a stale checkout path without removing the repo.",
|
|
107
109
|
"/work-plan init-repo myproject --github=your-org/myproject"),
|
|
110
|
+
("remove-repo", "<key>",
|
|
111
|
+
"Unregister a repo: delete its block from your config (config-only). The notes folder, any tracks, and the local clone are LEFT UNTOUCHED — if a notes folder or tracks reference it they're now orphaned and can be cleaned up by hand.",
|
|
112
|
+
"When you stop tracking a repo and want it out of the sidebar/brief without deleting your notes. Completes the add/update/remove trio with init-repo.",
|
|
113
|
+
"/work-plan remove-repo myproject"),
|
|
108
114
|
("suggest-priorities", "[--repo=<folder>] [--apply]",
|
|
109
115
|
"AI-assisted batch backfill of priority/PN labels.",
|
|
110
116
|
"ONE-TIME setup, or whenever a wave of new unlabeled issues piles up.",
|
|
@@ -141,6 +147,10 @@ DESCRIPTIONS = [
|
|
|
141
147
|
"Emit the viewer-ready JSON read surface (schema 1): every frontmatter'd track with repo, tier, status, visibility, blockers, next_up, an open/closed rollup, and per-issue state/assignee/milestone. Read-only; derives live from gh. Consumed by the VS Code extension.",
|
|
142
148
|
"When a tool (the VS Code viewer, or any script) needs structured track state instead of the human-facing brief/orient text.",
|
|
143
149
|
"/work-plan export --json"),
|
|
150
|
+
("list-open-issues", "--repo=<owner/name> [--exclude=<csv-issue-numbers>]",
|
|
151
|
+
"Emit a repo's OPEN issues as JSON ({repo, issues:[{number,title,state,assignee,milestone}]}) — the same issue shape as export. Read-only; derives live from gh. --repo takes a bare org/repo slug; --exclude drops the given issue numbers (the viewer passes a track's current issues so already-slotted ones don't reappear). Unlike export's `untracked`, this includes issues tracked by OTHER tracks, since those are valid slot targets.",
|
|
152
|
+
"When the VS Code viewer's Slot command needs the repo's open issues as a pick-list (the per-track export can't supply issues not yet in the track).",
|
|
153
|
+
"/work-plan list-open-issues --repo=stylusnexus/work-plan-toolkit --exclude=87,91"),
|
|
144
154
|
("set", "<track | track@repo> field=value [field=value …] [--repo=<key>] [--confirm=<token>]",
|
|
145
155
|
"Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
|
|
146
156
|
"Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
|
|
@@ -258,7 +268,7 @@ def main(argv: list[str]) -> int:
|
|
|
258
268
|
# (Flag aliases like --brief/--plan-status normalise by stripping leading dashes.)
|
|
259
269
|
_READONLY_SUBCOMMANDS = frozenset({
|
|
260
270
|
"brief", "orient", "where-was-i", "list", "coverage", "duplicates",
|
|
261
|
-
"plan-status", "export", "notes-vcs",
|
|
271
|
+
"plan-status", "export", "list-open-issues", "notes-vcs",
|
|
262
272
|
# plan-branch manages its OWN commits on the plan branch (init seeds +
|
|
263
273
|
# commits the skeleton itself); the auto-commit hooks must not also fire.
|
|
264
274
|
"plan-branch",
|