@stylusnexus/work-plan 2026.6.13 → 2026.6.14
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 +19 -4
- 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/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +70 -5
- package/skills/work-plan/commands/in_progress.py +110 -0
- 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_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +65 -1
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +42 -5
- package/skills/work-plan/lib/git_state.py +32 -0
- package/skills/work-plan/lib/github_state.py +132 -4
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/manifest.py +18 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- 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 +161 -8
- package/skills/work-plan/tests/test_export_command.py +103 -0
- package/skills/work-plan/tests/test_git_state.py +38 -1
- package/skills/work-plan/tests/test_github_state.py +66 -0
- package/skills/work-plan/tests/test_in_progress.py +43 -0
- package/skills/work-plan/tests/test_in_progress_command.py +166 -0
- package/skills/work-plan/tests/test_list_open_issues.py +8 -3
- package/skills/work-plan/tests/test_manifest.py +30 -1
- 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_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- package/skills/work-plan/work_plan.py +36 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""in-progress label write (#271). Offline — gh subprocess mocked."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
|
+
|
|
13
|
+
from lib import github_state
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _proc(rc, stdout="", stderr=""):
|
|
17
|
+
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SetIssueInProgressHelperTest(unittest.TestCase):
|
|
21
|
+
def test_add_creates_label_then_adds_with_repo(self):
|
|
22
|
+
calls = []
|
|
23
|
+
def fake_run(args, **kw):
|
|
24
|
+
calls.append(args)
|
|
25
|
+
return _proc(0)
|
|
26
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
|
|
27
|
+
ok, msg = github_state.set_issue_in_progress("o/r", 271)
|
|
28
|
+
self.assertTrue(ok)
|
|
29
|
+
self.assertEqual(calls[0], [
|
|
30
|
+
"gh", "label", "create", "work-plan:in-progress", "--repo", "o/r",
|
|
31
|
+
"--color", "FBCA04", "--description", "Actively being worked (work-plan)",
|
|
32
|
+
"--force"])
|
|
33
|
+
self.assertEqual(calls[1], [
|
|
34
|
+
"gh", "issue", "edit", "271", "--repo", "o/r",
|
|
35
|
+
"--add-label", "work-plan:in-progress"])
|
|
36
|
+
|
|
37
|
+
def test_clear_removes_label_without_creating(self):
|
|
38
|
+
calls = []
|
|
39
|
+
with mock.patch("lib.github_state.subprocess.run",
|
|
40
|
+
side_effect=lambda args, **kw: calls.append(args) or _proc(0)):
|
|
41
|
+
ok, msg = github_state.set_issue_in_progress("o/r", 271, clear=True)
|
|
42
|
+
self.assertTrue(ok)
|
|
43
|
+
self.assertEqual(calls, [[
|
|
44
|
+
"gh", "issue", "edit", "271", "--repo", "o/r",
|
|
45
|
+
"--remove-label", "work-plan:in-progress"]])
|
|
46
|
+
|
|
47
|
+
def test_invalid_repo_rejected(self):
|
|
48
|
+
ok, msg = github_state.set_issue_in_progress("not-a-slug", 5)
|
|
49
|
+
self.assertFalse(ok)
|
|
50
|
+
self.assertIn("invalid repo", msg)
|
|
51
|
+
|
|
52
|
+
def test_gh_failure_surfaces_stderr(self):
|
|
53
|
+
with mock.patch("lib.github_state.subprocess.run",
|
|
54
|
+
return_value=_proc(1, stderr="no write access")):
|
|
55
|
+
ok, msg = github_state.set_issue_in_progress("o/r", 5)
|
|
56
|
+
self.assertFalse(ok)
|
|
57
|
+
self.assertIn("no write access", msg)
|
|
58
|
+
|
|
59
|
+
def test_never_raises(self):
|
|
60
|
+
with mock.patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
|
|
61
|
+
ok, msg = github_state.set_issue_in_progress("o/r", 5)
|
|
62
|
+
self.assertFalse(ok)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
from commands import in_progress as inprog_cmd
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _track(name, repo, issues):
|
|
69
|
+
return SimpleNamespace(name=name, repo=repo, folder=name,
|
|
70
|
+
has_frontmatter=True,
|
|
71
|
+
meta={"github": {"issues": issues}, "track": name})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class InProgressCommandTest(unittest.TestCase):
|
|
75
|
+
def _drive(self, args, tracks, vis="PRIVATE", write_ret=(True, "ok")):
|
|
76
|
+
with mock.patch("commands.in_progress.load_config", return_value={"repos": {}}), \
|
|
77
|
+
mock.patch("commands.in_progress.discover_tracks", return_value=tracks), \
|
|
78
|
+
mock.patch("commands.in_progress.needs_confirm", return_value=(vis != "PRIVATE")), \
|
|
79
|
+
mock.patch("commands.in_progress.set_issue_in_progress",
|
|
80
|
+
return_value=write_ret) as mw:
|
|
81
|
+
out, err = io.StringIO(), io.StringIO()
|
|
82
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
83
|
+
rc = inprog_cmd.run(args)
|
|
84
|
+
return rc, out.getvalue(), err.getvalue(), mw
|
|
85
|
+
|
|
86
|
+
def test_marks_resolving_repo_from_single_track(self):
|
|
87
|
+
rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])])
|
|
88
|
+
self.assertEqual(rc, 0)
|
|
89
|
+
mw.assert_called_once_with("o/r", 271, clear=False)
|
|
90
|
+
|
|
91
|
+
def test_clear_flag(self):
|
|
92
|
+
rc, out, err, mw = self._drive(["271", "--clear"], [_track("alpha", "o/r", [271])])
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
mw.assert_called_once_with("o/r", 271, clear=True)
|
|
95
|
+
|
|
96
|
+
def test_ambiguous_number_across_repos_rejected(self):
|
|
97
|
+
rc, out, err, mw = self._drive(
|
|
98
|
+
["271"], [_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
|
|
99
|
+
self.assertEqual(rc, 1)
|
|
100
|
+
mw.assert_not_called()
|
|
101
|
+
self.assertIn("ambiguous", (out + err).lower())
|
|
102
|
+
|
|
103
|
+
def test_repo_flag_disambiguates(self):
|
|
104
|
+
rc, out, err, mw = self._drive(
|
|
105
|
+
["271", "--repo=o/r2"],
|
|
106
|
+
[_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
|
|
107
|
+
self.assertEqual(rc, 0)
|
|
108
|
+
mw.assert_called_once_with("o/r2", 271, clear=False)
|
|
109
|
+
|
|
110
|
+
def test_public_repo_without_token_emits_needs_confirm(self):
|
|
111
|
+
rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
|
|
112
|
+
self.assertEqual(rc, 0)
|
|
113
|
+
self.assertIn("needs_confirm", out)
|
|
114
|
+
mw.assert_not_called()
|
|
115
|
+
|
|
116
|
+
def test_public_repo_with_valid_token_writes(self):
|
|
117
|
+
from lib.write_guard import make_token
|
|
118
|
+
token = make_token("o/r", "271")
|
|
119
|
+
rc, out, err, mw = self._drive(
|
|
120
|
+
[f"--confirm={token}", "271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
mw.assert_called_once_with("o/r", 271, clear=False)
|
|
123
|
+
|
|
124
|
+
def test_non_integer_rejected(self):
|
|
125
|
+
rc, out, err, mw = self._drive(["abc"], [_track("alpha", "o/r", [271])])
|
|
126
|
+
self.assertEqual(rc, 2)
|
|
127
|
+
mw.assert_not_called()
|
|
128
|
+
|
|
129
|
+
def test_unresolvable_number_returns_1(self):
|
|
130
|
+
rc, out, err, mw = self._drive(["999"], [_track("alpha", "o/r", [271])])
|
|
131
|
+
self.assertEqual(rc, 1)
|
|
132
|
+
mw.assert_not_called()
|
|
133
|
+
|
|
134
|
+
# --- _resolve_repo --repo validation tests ---
|
|
135
|
+
|
|
136
|
+
def test_repo_flag_matching_tracked_repo_allowed(self):
|
|
137
|
+
"""--repo=o/r2 when 271 is tracked in o/r2 → allowed (legit disambiguation)."""
|
|
138
|
+
rc, out, err, mw = self._drive(
|
|
139
|
+
["271", "--repo=o/r2"],
|
|
140
|
+
[_track("a", "o/r1", []), _track("b", "o/r2", [271])])
|
|
141
|
+
self.assertEqual(rc, 0)
|
|
142
|
+
mw.assert_called_once_with("o/r2", 271, clear=False)
|
|
143
|
+
|
|
144
|
+
def test_repo_flag_pointing_to_untracked_repo_rejected(self):
|
|
145
|
+
"""--repo=o/r2 when 271 is tracked only in o/r1 → rejected (typo guard)."""
|
|
146
|
+
rc, out, err, mw = self._drive(
|
|
147
|
+
["271", "--repo=o/r2"],
|
|
148
|
+
[_track("a", "o/r1", [271]), _track("b", "o/r2", [])])
|
|
149
|
+
self.assertEqual(rc, 1)
|
|
150
|
+
mw.assert_not_called()
|
|
151
|
+
combined = (out + err).lower()
|
|
152
|
+
self.assertTrue(
|
|
153
|
+
"refusing" in combined or "not" in combined,
|
|
154
|
+
f"expected 'refusing' or 'not' in stderr/stdout, got: {out!r} {err!r}")
|
|
155
|
+
|
|
156
|
+
def test_repo_flag_for_issue_tracked_nowhere_allowed(self):
|
|
157
|
+
"""--repo=o/r9 when 271 is not in any track → allowed (explicit target)."""
|
|
158
|
+
rc, out, err, mw = self._drive(
|
|
159
|
+
["271", "--repo=o/r9"],
|
|
160
|
+
[_track("a", "o/r1", [99])])
|
|
161
|
+
self.assertEqual(rc, 0)
|
|
162
|
+
mw.assert_called_once_with("o/r9", 271, clear=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
unittest.main()
|
|
@@ -45,15 +45,20 @@ class ListOpenIssuesTest(unittest.TestCase):
|
|
|
45
45
|
rc, out = _run(["--repo=o/r"], rows)
|
|
46
46
|
self.assertEqual(rc, 0)
|
|
47
47
|
self.assertEqual(out["repo"], "o/r")
|
|
48
|
-
# Same Issue shape as the export (number/title/state/assignee/milestone
|
|
48
|
+
# Same Issue shape as the export (number/title/state/assignee/milestone,
|
|
49
|
+
# plus the always-present in_progress flag — #271 keeps the two surfaces
|
|
50
|
+
# identical, so list-open-issues carries it too, default False here since
|
|
51
|
+
# this command has no track/branch context).
|
|
49
52
|
self.assertEqual(
|
|
50
53
|
out["issues"][0],
|
|
51
54
|
{"number": 91, "title": "Rate-limit login", "state": "open",
|
|
52
|
-
"assignee": "@eve", "milestone": "v0.6"
|
|
55
|
+
"assignee": "@eve", "milestone": "v0.6", "in_progress": False,
|
|
56
|
+
"in_progress_label": False, "blocked_by": [], "blocking": []},
|
|
53
57
|
)
|
|
54
58
|
self.assertEqual(out["issues"][1],
|
|
55
59
|
{"number": 87, "title": "Fix auth", "state": "open",
|
|
56
|
-
"assignee": "—", "milestone": None
|
|
60
|
+
"assignee": "—", "milestone": None, "in_progress": False,
|
|
61
|
+
"in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
57
62
|
|
|
58
63
|
def test_exclude_filters_given_numbers(self):
|
|
59
64
|
rows = [_row(1), _row(2), _row(3)]
|
|
@@ -11,7 +11,7 @@ from lib.manifest import (
|
|
|
11
11
|
DeclaredPath, strip_range, parse_declared_paths,
|
|
12
12
|
count_checkboxes, plan_date_from_filename,
|
|
13
13
|
ManifestScore, score_manifest,
|
|
14
|
-
is_in_tree, out_of_tree_ratio,
|
|
14
|
+
is_in_tree, out_of_tree_ratio, offtree_declared_paths,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
@@ -158,5 +158,34 @@ class ScoreManifestTest(unittest.TestCase):
|
|
|
158
158
|
self.assertIsNone(score.pct)
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
class OfftreeDeclaredPathsTest(unittest.TestCase):
|
|
162
|
+
ROOT = "/repo"
|
|
163
|
+
|
|
164
|
+
def _decls(self, *paths):
|
|
165
|
+
return [DeclaredPath(kind="create", path=p) for p in paths]
|
|
166
|
+
|
|
167
|
+
def test_in_tree_paths_are_not_flagged(self):
|
|
168
|
+
decls = self._decls("src/a.ts", "src/b.ts")
|
|
169
|
+
self.assertEqual(offtree_declared_paths(decls, self.ROOT), [])
|
|
170
|
+
|
|
171
|
+
def test_flags_absolute_tilde_and_escape(self):
|
|
172
|
+
decls = self._decls("src/ok.ts", "/etc/passwd", "~/secrets.txt", "../sibling/x.ts")
|
|
173
|
+
self.assertEqual(
|
|
174
|
+
offtree_declared_paths(decls, self.ROOT),
|
|
175
|
+
["/etc/passwd", "~/secrets.txt", "../sibling/x.ts"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def test_flags_junk_root_slash(self):
|
|
179
|
+
# The literal `/` #164's smoke caught — resolves to the filesystem root.
|
|
180
|
+
self.assertEqual(offtree_declared_paths(self._decls("/x/y"), self.ROOT), ["/x/y"])
|
|
181
|
+
|
|
182
|
+
def test_dedups_preserving_first_seen_order(self):
|
|
183
|
+
decls = self._decls("../x.ts", "src/ok.ts", "../x.ts")
|
|
184
|
+
self.assertEqual(offtree_declared_paths(decls, self.ROOT), ["../x.ts"])
|
|
185
|
+
|
|
186
|
+
def test_empty_decls(self):
|
|
187
|
+
self.assertEqual(offtree_declared_paths([], self.ROOT), [])
|
|
188
|
+
|
|
189
|
+
|
|
161
190
|
if __name__ == "__main__":
|
|
162
191
|
unittest.main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""plan-ack (#286 slice 1): durable, frontmatter-only acknowledgment. Real temp
|
|
2
|
+
repo so the write round-trips through real yq; config + visibility are mocked."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import plan_ack
|
|
16
|
+
from lib import frontmatter
|
|
17
|
+
from lib.write_guard import make_token
|
|
18
|
+
|
|
19
|
+
REL = "docs/superpowers/plans/p.md"
|
|
20
|
+
BODY = "# Plan\n\nbody the writer must never touch\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlanAckTest(unittest.TestCase):
|
|
24
|
+
def _repo(self, d, doc_text=BODY):
|
|
25
|
+
root = Path(d)
|
|
26
|
+
(root / "docs/superpowers/plans").mkdir(parents=True)
|
|
27
|
+
(root / REL).write_text(doc_text)
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
def _drive(self, root, args, slug=None, vis="PRIVATE"):
|
|
31
|
+
cfg = {"notes_root": str(root), "repos": {}}
|
|
32
|
+
with mock.patch("commands.plan_ack.config_mod.load_config", return_value=cfg), \
|
|
33
|
+
mock.patch("commands.plan_ack.config_mod.resolve_local_path_for_folder",
|
|
34
|
+
return_value=root), \
|
|
35
|
+
mock.patch("commands.plan_ack.config_mod.resolve_github_for_folder",
|
|
36
|
+
return_value=slug), \
|
|
37
|
+
mock.patch("lib.write_guard.repo_visibility", return_value=vis):
|
|
38
|
+
out, err = io.StringIO(), io.StringIO()
|
|
39
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
40
|
+
rc = plan_ack.run(args)
|
|
41
|
+
return rc, out.getvalue(), err.getvalue()
|
|
42
|
+
|
|
43
|
+
def test_writes_acknowledged_preserving_body(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as d:
|
|
45
|
+
root = self._repo(d)
|
|
46
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", REL])
|
|
47
|
+
self.assertEqual(rc, 0)
|
|
48
|
+
meta, body = frontmatter.parse_file(root / REL)
|
|
49
|
+
self.assertIs(meta["acknowledged"], True)
|
|
50
|
+
self.assertEqual(body, BODY)
|
|
51
|
+
self.assertIn("frontmatter only", out)
|
|
52
|
+
|
|
53
|
+
def test_clear_removes_acknowledged(self):
|
|
54
|
+
with tempfile.TemporaryDirectory() as d:
|
|
55
|
+
root = self._repo(d, f"---\nacknowledged: true\n---\n{BODY}")
|
|
56
|
+
rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
|
|
57
|
+
self.assertEqual(rc, 0)
|
|
58
|
+
meta, body = frontmatter.parse_file(root / REL)
|
|
59
|
+
self.assertNotIn("acknowledged", meta)
|
|
60
|
+
self.assertEqual(body, BODY)
|
|
61
|
+
|
|
62
|
+
def test_clear_when_absent_is_noop(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as d:
|
|
64
|
+
root = self._repo(d)
|
|
65
|
+
rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
|
|
66
|
+
self.assertEqual(rc, 0)
|
|
67
|
+
self.assertIn("nothing to clear", out)
|
|
68
|
+
|
|
69
|
+
def test_public_repo_no_token_returns_needs_confirm(self):
|
|
70
|
+
with tempfile.TemporaryDirectory() as d:
|
|
71
|
+
root = self._repo(d)
|
|
72
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", REL],
|
|
73
|
+
slug="org/pub", vis="PUBLIC")
|
|
74
|
+
self.assertEqual(rc, 0)
|
|
75
|
+
data = json.loads(out)
|
|
76
|
+
self.assertTrue(data["needs_confirm"])
|
|
77
|
+
self.assertEqual(data["token"], make_token("org/pub", REL))
|
|
78
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
79
|
+
self.assertNotIn("acknowledged", meta) # no write happened
|
|
80
|
+
|
|
81
|
+
def test_public_repo_with_valid_token_writes(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as d:
|
|
83
|
+
root = self._repo(d)
|
|
84
|
+
token = make_token("org/pub", REL)
|
|
85
|
+
rc, out, err = self._drive(root, ["--repo=k", f"--confirm={token}", "--", REL],
|
|
86
|
+
slug="org/pub", vis="PUBLIC")
|
|
87
|
+
self.assertEqual(rc, 0)
|
|
88
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
89
|
+
self.assertIs(meta["acknowledged"], True)
|
|
90
|
+
|
|
91
|
+
def test_path_escape_rejected(self):
|
|
92
|
+
with tempfile.TemporaryDirectory() as d:
|
|
93
|
+
root = self._repo(d)
|
|
94
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", "../../etc/passwd"])
|
|
95
|
+
self.assertEqual(rc, 1)
|
|
96
|
+
self.assertIn("not a file inside", err)
|
|
97
|
+
|
|
98
|
+
def test_missing_repo_flag_rejected(self):
|
|
99
|
+
rc, out, err = self._drive(Path("/tmp"), ["--", REL])
|
|
100
|
+
self.assertEqual(rc, 2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
unittest.main()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""plan-baseline (#286 slice 2): stamp the computed verdict to frontmatter as a
|
|
2
|
+
drift baseline. Real temp repo (real yq); config + git date mocked."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import plan_baseline
|
|
16
|
+
from lib import frontmatter
|
|
17
|
+
from lib.write_guard import make_token
|
|
18
|
+
|
|
19
|
+
REL = "docs/superpowers/plans/p.md"
|
|
20
|
+
# A plan whose one declared file exists → mechanical verdict "shipped".
|
|
21
|
+
BODY = "# Plan\n\n**Files:**\n- Create: `src/new.ts`\n- [ ] Step 1\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlanBaselineTest(unittest.TestCase):
|
|
25
|
+
def _repo(self, d, with_file=True, doc_text=BODY):
|
|
26
|
+
root = Path(d)
|
|
27
|
+
(root / "docs/superpowers/plans").mkdir(parents=True)
|
|
28
|
+
(root / REL).write_text(doc_text)
|
|
29
|
+
if with_file:
|
|
30
|
+
(root / "src").mkdir()
|
|
31
|
+
(root / "src/new.ts").write_text("export const x = 1")
|
|
32
|
+
return root
|
|
33
|
+
|
|
34
|
+
def _drive(self, root, args, slug=None, vis="PRIVATE"):
|
|
35
|
+
cfg = {"notes_root": str(root), "repos": {}}
|
|
36
|
+
with mock.patch("commands.plan_baseline.config_mod.load_config", return_value=cfg), \
|
|
37
|
+
mock.patch("commands.plan_baseline.config_mod.resolve_local_path_for_folder",
|
|
38
|
+
return_value=root), \
|
|
39
|
+
mock.patch("commands.plan_baseline.config_mod.resolve_github_for_folder",
|
|
40
|
+
return_value=slug), \
|
|
41
|
+
mock.patch("commands.plan_status.git_state.path_last_commit_date",
|
|
42
|
+
return_value=None), \
|
|
43
|
+
mock.patch("lib.write_guard.repo_visibility", return_value=vis):
|
|
44
|
+
out, err = io.StringIO(), io.StringIO()
|
|
45
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
46
|
+
rc = plan_baseline.run(args)
|
|
47
|
+
return rc, out.getvalue(), err.getvalue()
|
|
48
|
+
|
|
49
|
+
def test_stamps_computed_verdict(self):
|
|
50
|
+
with tempfile.TemporaryDirectory() as d:
|
|
51
|
+
root = self._repo(d) # file present → shipped
|
|
52
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", REL])
|
|
53
|
+
self.assertEqual(rc, 0)
|
|
54
|
+
meta, body = frontmatter.parse_file(root / REL)
|
|
55
|
+
self.assertEqual(meta["verdict_baseline"], "shipped")
|
|
56
|
+
self.assertIn("shipped", out)
|
|
57
|
+
|
|
58
|
+
def test_clear_removes_baseline(self):
|
|
59
|
+
with tempfile.TemporaryDirectory() as d:
|
|
60
|
+
root = self._repo(d, doc_text=f"---\nverdict_baseline: shipped\n---\n{BODY}")
|
|
61
|
+
rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
|
|
62
|
+
self.assertEqual(rc, 0)
|
|
63
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
64
|
+
self.assertNotIn("verdict_baseline", meta)
|
|
65
|
+
|
|
66
|
+
def test_public_repo_no_token_returns_needs_confirm(self):
|
|
67
|
+
with tempfile.TemporaryDirectory() as d:
|
|
68
|
+
root = self._repo(d)
|
|
69
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", REL],
|
|
70
|
+
slug="org/pub", vis="PUBLIC")
|
|
71
|
+
self.assertEqual(rc, 0)
|
|
72
|
+
data = json.loads(out)
|
|
73
|
+
self.assertEqual(data["token"], make_token("org/pub", REL))
|
|
74
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
75
|
+
self.assertNotIn("verdict_baseline", meta)
|
|
76
|
+
|
|
77
|
+
def test_path_escape_rejected(self):
|
|
78
|
+
with tempfile.TemporaryDirectory() as d:
|
|
79
|
+
root = self._repo(d)
|
|
80
|
+
rc, out, err = self._drive(root, ["--repo=k", "--", "../../etc/passwd"])
|
|
81
|
+
self.assertEqual(rc, 1)
|
|
82
|
+
self.assertIn("not a file inside", err)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
unittest.main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""plan-confirm (#286): frontmatter-only verdict_override writes, with the
|
|
2
|
+
public-repo confirm-token gate. Uses a real temp repo so the frontmatter write
|
|
3
|
+
round-trips through real yq; config + visibility are mocked (offline)."""
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import unittest
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
10
|
+
from pathlib import Path
|
|
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 plan_confirm
|
|
17
|
+
from lib import frontmatter
|
|
18
|
+
from lib.write_guard import make_token
|
|
19
|
+
|
|
20
|
+
REL = "docs/superpowers/plans/p.md"
|
|
21
|
+
BODY = "# Idea Mode UI\n\nbody text the writer must never touch\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlanConfirmTest(unittest.TestCase):
|
|
25
|
+
def _repo(self, d, doc_text=BODY):
|
|
26
|
+
root = Path(d)
|
|
27
|
+
(root / "docs/superpowers/plans").mkdir(parents=True)
|
|
28
|
+
(root / REL).write_text(doc_text)
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
def _drive(self, root, args, slug=None, vis="PRIVATE"):
|
|
32
|
+
cfg = {"notes_root": str(root), "repos": {}}
|
|
33
|
+
with mock.patch("commands.plan_confirm.config_mod.load_config", return_value=cfg), \
|
|
34
|
+
mock.patch("commands.plan_confirm.config_mod.resolve_local_path_for_folder",
|
|
35
|
+
return_value=root), \
|
|
36
|
+
mock.patch("commands.plan_confirm.config_mod.resolve_github_for_folder",
|
|
37
|
+
return_value=slug), \
|
|
38
|
+
mock.patch("lib.write_guard.repo_visibility", return_value=vis):
|
|
39
|
+
out, err = io.StringIO(), io.StringIO()
|
|
40
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
41
|
+
rc = plan_confirm.run(args)
|
|
42
|
+
# stdout carries the machine surfaces (needs_confirm JSON, success line);
|
|
43
|
+
# stderr carries usage/validation errors. Tests assert on either.
|
|
44
|
+
return rc, out.getvalue(), err.getvalue()
|
|
45
|
+
|
|
46
|
+
def test_writes_override_to_frontmatter_preserving_body(self):
|
|
47
|
+
with tempfile.TemporaryDirectory() as d:
|
|
48
|
+
root = self._repo(d)
|
|
49
|
+
rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--", REL])
|
|
50
|
+
self.assertEqual(rc, 0)
|
|
51
|
+
meta, body = frontmatter.parse_file(root / REL)
|
|
52
|
+
self.assertEqual(meta["verdict_override"], "shipped")
|
|
53
|
+
self.assertEqual(body, BODY) # body byte-preserved
|
|
54
|
+
self.assertIn("frontmatter only", out)
|
|
55
|
+
|
|
56
|
+
def test_clear_removes_override(self):
|
|
57
|
+
with tempfile.TemporaryDirectory() as d:
|
|
58
|
+
root = self._repo(d, f"---\nverdict_override: shipped\n---\n{BODY}")
|
|
59
|
+
rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
|
|
60
|
+
self.assertEqual(rc, 0)
|
|
61
|
+
meta, body = frontmatter.parse_file(root / REL)
|
|
62
|
+
self.assertNotIn("verdict_override", meta)
|
|
63
|
+
self.assertEqual(body, BODY)
|
|
64
|
+
|
|
65
|
+
def test_public_repo_no_token_returns_needs_confirm(self):
|
|
66
|
+
with tempfile.TemporaryDirectory() as d:
|
|
67
|
+
root = self._repo(d)
|
|
68
|
+
rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--", REL],
|
|
69
|
+
slug="org/pub", vis="PUBLIC")
|
|
70
|
+
self.assertEqual(rc, 0)
|
|
71
|
+
data = json.loads(out)
|
|
72
|
+
self.assertTrue(data["needs_confirm"])
|
|
73
|
+
self.assertEqual(data["token"], make_token("org/pub", REL))
|
|
74
|
+
# NO write happened.
|
|
75
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
76
|
+
self.assertNotIn("verdict_override", meta)
|
|
77
|
+
|
|
78
|
+
def test_public_repo_with_valid_token_writes(self):
|
|
79
|
+
with tempfile.TemporaryDirectory() as d:
|
|
80
|
+
root = self._repo(d)
|
|
81
|
+
token = make_token("org/pub", REL)
|
|
82
|
+
rc, out, err = self._drive(
|
|
83
|
+
root, ["--repo=k", "--verdict=shipped", f"--confirm={token}", "--", REL],
|
|
84
|
+
slug="org/pub", vis="PUBLIC")
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
meta, _ = frontmatter.parse_file(root / REL)
|
|
87
|
+
self.assertEqual(meta["verdict_override"], "shipped")
|
|
88
|
+
|
|
89
|
+
def test_path_escape_rejected(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as d:
|
|
91
|
+
root = self._repo(d)
|
|
92
|
+
rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--",
|
|
93
|
+
"../../etc/passwd"])
|
|
94
|
+
self.assertEqual(rc, 1)
|
|
95
|
+
self.assertIn("not a file inside", err)
|
|
96
|
+
|
|
97
|
+
def test_invalid_verdict_rejected(self):
|
|
98
|
+
with tempfile.TemporaryDirectory() as d:
|
|
99
|
+
root = self._repo(d)
|
|
100
|
+
rc, out, err = self._drive(root, ["--repo=k", "--verdict=done", "--", REL])
|
|
101
|
+
self.assertEqual(rc, 2)
|
|
102
|
+
|
|
103
|
+
def test_missing_repo_flag_rejected(self):
|
|
104
|
+
rc, out, err = self._drive(Path("/tmp"), ["--verdict=shipped", "--", REL])
|
|
105
|
+
self.assertEqual(rc, 2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
unittest.main()
|