@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2
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 +38 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- package/skills/work-plan/commands/group.py +5 -1
- 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/new_track.py +8 -2
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- 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/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -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_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_push_track.py +131 -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/tests/test_set_field.py +60 -0
- package/skills/work-plan/work_plan.py +125 -6
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""push-track (#306): promote a private track to the shared tier + push.
|
|
2
|
+
Real temp files for the move; git-worktree helpers + config mocked (offline)."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from commands import push_track
|
|
17
|
+
from lib.frontmatter import parse_file
|
|
18
|
+
from lib.write_guard import make_token
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PushTrackTest(unittest.TestCase):
|
|
22
|
+
def _setup(self, d, tier="private", plan_branch="work-plan/plan"):
|
|
23
|
+
root = Path(d)
|
|
24
|
+
notes = root / "notes"; notes.mkdir()
|
|
25
|
+
priv = notes / "my-feature.md"
|
|
26
|
+
priv.write_text("---\ntrack: my-feature\n---\n# My Feature\n\nbody\n")
|
|
27
|
+
shared = root / "repo" / ".work-plan"; shared.mkdir(parents=True)
|
|
28
|
+
entry = {"github": "o/r", "local": str(root / "repo")}
|
|
29
|
+
if plan_branch:
|
|
30
|
+
entry["plan_branch"] = plan_branch
|
|
31
|
+
cfg = {"notes_root": str(notes), "repos": {"demo": entry}}
|
|
32
|
+
track = SimpleNamespace(
|
|
33
|
+
name="my-feature", tier=tier, folder="demo", repo="o/r",
|
|
34
|
+
path=priv, meta={"track": "my-feature"}, body="# My Feature\n\nbody\n",
|
|
35
|
+
)
|
|
36
|
+
return root, cfg, track, shared, priv
|
|
37
|
+
|
|
38
|
+
def _drive(self, cfg, track, shared, args, vis="PRIVATE",
|
|
39
|
+
commit_sha="abc123", push_rc=0):
|
|
40
|
+
push_proc = SimpleNamespace(returncode=push_rc, stderr="")
|
|
41
|
+
with mock.patch("commands.push_track.load_config", return_value=cfg), \
|
|
42
|
+
mock.patch("commands.push_track.discover_tracks", return_value=[track]), \
|
|
43
|
+
mock.patch("commands.push_track.find_track_by_name", return_value=track), \
|
|
44
|
+
mock.patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
45
|
+
mock.patch("commands.push_track.pw.shared_tier_dir", return_value=shared), \
|
|
46
|
+
mock.patch("commands.push_track.pw.commit_shared_tier", return_value=commit_sha), \
|
|
47
|
+
mock.patch("commands.push_track.pw.push_plan_branch", return_value=push_proc) as mpush:
|
|
48
|
+
out, err = io.StringIO(), io.StringIO()
|
|
49
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
50
|
+
rc = push_track.run(args)
|
|
51
|
+
return rc, out.getvalue(), err.getvalue(), mpush
|
|
52
|
+
|
|
53
|
+
def test_promotes_and_pushes_private_repo(self):
|
|
54
|
+
with tempfile.TemporaryDirectory() as d:
|
|
55
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
56
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
|
|
57
|
+
self.assertEqual(rc, 0)
|
|
58
|
+
dest = shared / "my-feature.md"
|
|
59
|
+
self.assertTrue(dest.is_file()) # written to shared tier
|
|
60
|
+
self.assertFalse(priv.exists()) # private copy removed
|
|
61
|
+
meta, _ = parse_file(dest)
|
|
62
|
+
self.assertEqual(meta["track"], "my-feature") # frontmatter preserved
|
|
63
|
+
mpush.assert_called_once() # pushed
|
|
64
|
+
|
|
65
|
+
def test_no_push_keeps_local(self):
|
|
66
|
+
with tempfile.TemporaryDirectory() as d:
|
|
67
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
68
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature", "--no-push"])
|
|
69
|
+
self.assertEqual(rc, 0)
|
|
70
|
+
self.assertTrue((shared / "my-feature.md").is_file())
|
|
71
|
+
mpush.assert_not_called()
|
|
72
|
+
self.assertIn("plan-branch push", out)
|
|
73
|
+
|
|
74
|
+
def test_public_repo_no_token_returns_needs_confirm_no_mutation(self):
|
|
75
|
+
with tempfile.TemporaryDirectory() as d:
|
|
76
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
77
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"], vis="PUBLIC")
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
data = json.loads(out)
|
|
80
|
+
self.assertTrue(data["needs_confirm"])
|
|
81
|
+
self.assertEqual(data["token"], make_token("o/r", "my-feature"))
|
|
82
|
+
self.assertFalse((shared / "my-feature.md").exists()) # nothing moved
|
|
83
|
+
self.assertTrue(priv.exists()) # private intact
|
|
84
|
+
mpush.assert_not_called()
|
|
85
|
+
|
|
86
|
+
def test_public_repo_with_valid_token_proceeds(self):
|
|
87
|
+
with tempfile.TemporaryDirectory() as d:
|
|
88
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
89
|
+
tok = make_token("o/r", "my-feature")
|
|
90
|
+
rc, out, err, mpush = self._drive(
|
|
91
|
+
cfg, track, shared, ["my-feature", f"--confirm={tok}"], vis="PUBLIC")
|
|
92
|
+
self.assertEqual(rc, 0)
|
|
93
|
+
self.assertTrue((shared / "my-feature.md").is_file())
|
|
94
|
+
mpush.assert_called_once()
|
|
95
|
+
|
|
96
|
+
def test_public_no_push_skips_gate(self):
|
|
97
|
+
# --no-push keeps it local, so no exposure gate even on a public repo.
|
|
98
|
+
with tempfile.TemporaryDirectory() as d:
|
|
99
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
100
|
+
rc, out, err, mpush = self._drive(
|
|
101
|
+
cfg, track, shared, ["my-feature", "--no-push"], vis="PUBLIC")
|
|
102
|
+
self.assertEqual(rc, 0)
|
|
103
|
+
self.assertTrue((shared / "my-feature.md").is_file())
|
|
104
|
+
mpush.assert_not_called()
|
|
105
|
+
|
|
106
|
+
def test_already_shared_aborts(self):
|
|
107
|
+
with tempfile.TemporaryDirectory() as d:
|
|
108
|
+
_, cfg, track, shared, priv = self._setup(d, tier="shared")
|
|
109
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
|
|
110
|
+
self.assertEqual(rc, 1)
|
|
111
|
+
self.assertIn("already in the shared tier", err)
|
|
112
|
+
|
|
113
|
+
def test_no_plan_branch_hints_init(self):
|
|
114
|
+
with tempfile.TemporaryDirectory() as d:
|
|
115
|
+
_, cfg, track, shared, priv = self._setup(d, plan_branch=None)
|
|
116
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
|
|
117
|
+
self.assertEqual(rc, 1)
|
|
118
|
+
self.assertIn("plan-branch init", err)
|
|
119
|
+
|
|
120
|
+
def test_dest_exists_aborts(self):
|
|
121
|
+
with tempfile.TemporaryDirectory() as d:
|
|
122
|
+
_, cfg, track, shared, priv = self._setup(d)
|
|
123
|
+
(shared / "my-feature.md").write_text("# already here\n")
|
|
124
|
+
rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
|
|
125
|
+
self.assertEqual(rc, 1)
|
|
126
|
+
self.assertIn("already exists", err)
|
|
127
|
+
self.assertTrue(priv.exists()) # private not removed on abort
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
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()
|
|
@@ -75,3 +75,63 @@ class SetFieldTest(unittest.TestCase):
|
|
|
75
75
|
self.assertEqual(rc, 0)
|
|
76
76
|
mw.assert_not_called()
|
|
77
77
|
self.assertIn("needs_confirm", out)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SetFieldPlanTest(unittest.TestCase):
|
|
81
|
+
"""`set <track> plan=<rel>` — the #285 track↔plan frontmatter link."""
|
|
82
|
+
|
|
83
|
+
def _track(self, folder="demo", meta=None):
|
|
84
|
+
return SimpleNamespace(
|
|
85
|
+
name="ph", repo="o/r", folder=folder, path=Path("/tmp/ph.md"),
|
|
86
|
+
has_frontmatter=True, meta=meta if meta is not None else {"status": "active"},
|
|
87
|
+
body="# b")
|
|
88
|
+
|
|
89
|
+
def _drive_plan(self, args, track, cfg=None):
|
|
90
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
91
|
+
if cfg:
|
|
92
|
+
base_cfg.update(cfg)
|
|
93
|
+
with patch("commands.set_field.load_config", return_value=base_cfg), \
|
|
94
|
+
patch("commands.set_field.discover_tracks", return_value=[track]), \
|
|
95
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
96
|
+
patch("commands.set_field.resolve_local_path_for_folder", return_value=None), \
|
|
97
|
+
patch("commands.set_field.write_file") as mw:
|
|
98
|
+
buf = io.StringIO()
|
|
99
|
+
with redirect_stdout(buf):
|
|
100
|
+
rc = set_field.run(args)
|
|
101
|
+
return rc, mw, buf.getvalue()
|
|
102
|
+
|
|
103
|
+
def test_sets_plan_path(self):
|
|
104
|
+
t = self._track()
|
|
105
|
+
rc, mw, out = self._drive_plan(["ph", "plan=docs/plans/p.md"], t)
|
|
106
|
+
self.assertEqual(rc, 0)
|
|
107
|
+
mw.assert_called_once()
|
|
108
|
+
self.assertEqual(mw.call_args[0][1]["plan"], "docs/plans/p.md")
|
|
109
|
+
|
|
110
|
+
def test_empty_plan_clears_link(self):
|
|
111
|
+
t = self._track(meta={"status": "active", "plan": "docs/plans/old.md"})
|
|
112
|
+
rc, mw, out = self._drive_plan(["ph", "plan="], t)
|
|
113
|
+
self.assertEqual(rc, 0)
|
|
114
|
+
mw.assert_called_once()
|
|
115
|
+
self.assertNotIn("plan", mw.call_args[0][1]) # key removed, not written as ""
|
|
116
|
+
self.assertIn("cleared", out)
|
|
117
|
+
|
|
118
|
+
def test_unresolved_plan_path_warns_but_saves(self):
|
|
119
|
+
# local path exists but the file doesn't -> WARN on stderr, still writes.
|
|
120
|
+
import tempfile
|
|
121
|
+
with tempfile.TemporaryDirectory() as d:
|
|
122
|
+
t = self._track()
|
|
123
|
+
with patch("commands.set_field.load_config", return_value={"notes_root": "/tmp"}), \
|
|
124
|
+
patch("commands.set_field.discover_tracks", return_value=[t]), \
|
|
125
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
126
|
+
patch("commands.set_field.resolve_local_path_for_folder",
|
|
127
|
+
return_value=Path(d)), \
|
|
128
|
+
patch("commands.set_field.write_file") as mw:
|
|
129
|
+
err = io.StringIO()
|
|
130
|
+
from contextlib import redirect_stderr
|
|
131
|
+
buf = io.StringIO()
|
|
132
|
+
with redirect_stdout(buf), redirect_stderr(err):
|
|
133
|
+
rc = set_field.run(["ph", "plan=docs/plans/missing.md"])
|
|
134
|
+
self.assertEqual(rc, 0)
|
|
135
|
+
mw.assert_called_once()
|
|
136
|
+
self.assertEqual(mw.call_args[0][1]["plan"], "docs/plans/missing.md")
|
|
137
|
+
self.assertIn("does not resolve", err.getvalue())
|