@stylusnexus/work-plan 2026.6.9-1
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 +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -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 +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -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 +88 -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 +42 -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/notes_readme.py +38 -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 +248 -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_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -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_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -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 +169 -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_group_apply.py +348 -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 +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -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 +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -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 +239 -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_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -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 +220 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Tests for the non-interactive new-track command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Creates a track for a config-key repo on a PRIVATE repo → write_file called
|
|
5
|
+
with the right meta (github.repo = key's github value; status active;
|
|
6
|
+
priority/milestone defaults), rc 0.
|
|
7
|
+
- Creates a track for a bare org/repo slug → folder = name part after '/',
|
|
8
|
+
github = full slug, rc 0.
|
|
9
|
+
- Public repo, no token → needs_confirm JSON, write_file NOT called, rc 0;
|
|
10
|
+
token == make_token(github, slug).
|
|
11
|
+
- Public repo, valid --confirm=<token> → creates (write_file called), rc 0.
|
|
12
|
+
- Existing path → rc 2, no write.
|
|
13
|
+
- Unknown repo (not a config key, no slash) → rc 1, no write.
|
|
14
|
+
- Invalid slug → rc 2.
|
|
15
|
+
- --priority=P1 --milestone=v2 reflected in meta; defaults applied when absent.
|
|
16
|
+
- --private accepted without error, creates normally, rc 0.
|
|
17
|
+
- "new-track" in SUBCOMMANDS and appears in DESCRIPTIONS.
|
|
18
|
+
"""
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import unittest
|
|
24
|
+
from contextlib import redirect_stdout
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from unittest.mock import patch, MagicMock, call
|
|
27
|
+
|
|
28
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
29
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
30
|
+
|
|
31
|
+
from commands import new_track
|
|
32
|
+
from lib.write_guard import make_token
|
|
33
|
+
import work_plan
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
NOTES_ROOT = "/tmp/fake-notes"
|
|
41
|
+
|
|
42
|
+
def _make_cfg(*, repos=None):
|
|
43
|
+
if repos is None:
|
|
44
|
+
repos = {
|
|
45
|
+
"myrepo": {"github": "org/myrepo", "local": None},
|
|
46
|
+
"critforge": {"github": "stylusnexus/critforge", "local": None},
|
|
47
|
+
}
|
|
48
|
+
return {"notes_root": NOTES_ROOT, "repos": repos}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _drive(args, *, vis="PRIVATE", notes_root_exists=True, target_path_exists=False):
|
|
52
|
+
"""Run new_track.run(args) with all external I/O mocked.
|
|
53
|
+
|
|
54
|
+
vis: what repo_visibility returns for needs_confirm.
|
|
55
|
+
notes_root_exists: whether notes_root directory exists.
|
|
56
|
+
target_path_exists: whether the target .md path already exists.
|
|
57
|
+
"""
|
|
58
|
+
cfg = _make_cfg()
|
|
59
|
+
|
|
60
|
+
def _path_exists(self):
|
|
61
|
+
# notes_root itself → notes_root_exists; target path → target_path_exists.
|
|
62
|
+
# Compare with Path equality (not str ==): on Windows str(Path("/tmp/x"))
|
|
63
|
+
# uses backslashes, so an exact "/tmp/fake-notes" string match never fires
|
|
64
|
+
# and the notes_root-missing case can't be simulated.
|
|
65
|
+
if self == Path(NOTES_ROOT):
|
|
66
|
+
return notes_root_exists
|
|
67
|
+
if self.suffix == ".md":
|
|
68
|
+
return target_path_exists
|
|
69
|
+
# archive dirs and parent dirs default to True
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
73
|
+
patch("commands.new_track.write_file") as mw, \
|
|
74
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
75
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
76
|
+
patch("pathlib.Path.mkdir"):
|
|
77
|
+
buf = io.StringIO()
|
|
78
|
+
with redirect_stdout(buf):
|
|
79
|
+
rc = new_track.run(args)
|
|
80
|
+
return rc, mw, buf.getvalue()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Test cases
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
class NewTrackCommandTest(unittest.TestCase):
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Registry checks
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def test_subcommand_registered_in_subcommands(self):
|
|
94
|
+
"""'new-track' must be in work_plan.SUBCOMMANDS."""
|
|
95
|
+
self.assertIn("new-track", work_plan.SUBCOMMANDS)
|
|
96
|
+
|
|
97
|
+
def test_subcommand_appears_in_descriptions(self):
|
|
98
|
+
"""'new-track' must appear in work_plan.DESCRIPTIONS."""
|
|
99
|
+
names = [entry[0] for entry in work_plan.DESCRIPTIONS]
|
|
100
|
+
self.assertIn("new-track", names)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Config-key repo (PRIVATE)
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def test_config_key_private_creates_track(self):
|
|
107
|
+
"""Config-key repo 'myrepo' (PRIVATE) → write_file called with
|
|
108
|
+
github.repo = 'org/myrepo', status = 'active', rc 0."""
|
|
109
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
110
|
+
self.assertEqual(rc, 0)
|
|
111
|
+
mw.assert_called_once()
|
|
112
|
+
meta = mw.call_args[0][1]
|
|
113
|
+
self.assertEqual(meta["github"]["repo"], "org/myrepo")
|
|
114
|
+
self.assertEqual(meta["status"], "active")
|
|
115
|
+
|
|
116
|
+
def test_config_key_folder_resolves_correctly(self):
|
|
117
|
+
"""Config-key 'critforge' → folder = 'critforge',
|
|
118
|
+
github.repo = 'stylusnexus/critforge'."""
|
|
119
|
+
rc, mw, out = _drive(["critforge", "encounter-builder"], vis="PRIVATE")
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
mw.assert_called_once()
|
|
122
|
+
meta = mw.call_args[0][1]
|
|
123
|
+
self.assertEqual(meta["github"]["repo"], "stylusnexus/critforge")
|
|
124
|
+
# Track name from slug
|
|
125
|
+
self.assertEqual(meta["track"], "encounter-builder")
|
|
126
|
+
# Path passed to write_file should be under critforge folder
|
|
127
|
+
path_arg = mw.call_args[0][0]
|
|
128
|
+
self.assertIn("critforge", str(path_arg))
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Bare org/repo slug
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def test_bare_org_repo_slug_uses_name_as_folder(self):
|
|
135
|
+
"""'stylusnexus/work-plan-toolkit' → folder = 'work-plan-toolkit',
|
|
136
|
+
github.repo = 'stylusnexus/work-plan-toolkit', rc 0."""
|
|
137
|
+
rc, mw, out = _drive(
|
|
138
|
+
["stylusnexus/work-plan-toolkit", "my-feature"], vis="PRIVATE"
|
|
139
|
+
)
|
|
140
|
+
self.assertEqual(rc, 0)
|
|
141
|
+
mw.assert_called_once()
|
|
142
|
+
meta = mw.call_args[0][1]
|
|
143
|
+
self.assertEqual(meta["github"]["repo"], "stylusnexus/work-plan-toolkit")
|
|
144
|
+
path_arg = mw.call_args[0][0]
|
|
145
|
+
self.assertIn("work-plan-toolkit", str(path_arg))
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Public repo — confirm gate
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
152
|
+
"""Public repo, no token → prints needs_confirm JSON, write_file
|
|
153
|
+
NOT called, rc 0; token == make_token(github, slug)."""
|
|
154
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PUBLIC")
|
|
155
|
+
self.assertEqual(rc, 0)
|
|
156
|
+
mw.assert_not_called()
|
|
157
|
+
data = json.loads(out.strip())
|
|
158
|
+
self.assertTrue(data["needs_confirm"])
|
|
159
|
+
self.assertEqual(data["token"], make_token("org/myrepo", "my-feature"))
|
|
160
|
+
self.assertIn("PUBLIC", data["reason"])
|
|
161
|
+
|
|
162
|
+
def test_public_repo_unknown_vis_returns_needs_confirm_json(self):
|
|
163
|
+
"""Unknown visibility (None) also requires confirm."""
|
|
164
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis=None)
|
|
165
|
+
self.assertEqual(rc, 0)
|
|
166
|
+
mw.assert_not_called()
|
|
167
|
+
data = json.loads(out.strip())
|
|
168
|
+
self.assertTrue(data["needs_confirm"])
|
|
169
|
+
|
|
170
|
+
def test_public_repo_wrong_token_blocked(self):
|
|
171
|
+
"""Wrong --confirm token → blocked, no write, rc 0."""
|
|
172
|
+
rc, mw, out = _drive(
|
|
173
|
+
["myrepo", "my-feature", "--confirm=wrongtoken"], vis="PUBLIC"
|
|
174
|
+
)
|
|
175
|
+
self.assertEqual(rc, 0)
|
|
176
|
+
mw.assert_not_called()
|
|
177
|
+
data = json.loads(out.strip())
|
|
178
|
+
self.assertTrue(data["needs_confirm"])
|
|
179
|
+
|
|
180
|
+
def test_public_repo_valid_token_writes(self):
|
|
181
|
+
"""Valid --confirm=<token> on a public repo → creates track, rc 0."""
|
|
182
|
+
tok = make_token("org/myrepo", "my-feature")
|
|
183
|
+
rc, mw, out = _drive(
|
|
184
|
+
["myrepo", "my-feature", f"--confirm={tok}"], vis="PUBLIC"
|
|
185
|
+
)
|
|
186
|
+
self.assertEqual(rc, 0)
|
|
187
|
+
mw.assert_called_once()
|
|
188
|
+
|
|
189
|
+
def test_bare_slug_public_token_uses_full_github(self):
|
|
190
|
+
"""Public bare slug: token is make_token(full-github, slug)."""
|
|
191
|
+
tok = make_token("org/other-repo", "new-slug")
|
|
192
|
+
rc, mw, out = _drive(
|
|
193
|
+
["org/other-repo", "new-slug", f"--confirm={tok}"], vis="PUBLIC"
|
|
194
|
+
)
|
|
195
|
+
self.assertEqual(rc, 0)
|
|
196
|
+
mw.assert_called_once()
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Existing path → rc 2
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def test_existing_path_returns_rc2_no_write(self):
|
|
203
|
+
"""Target .md already exists → rc 2, no write."""
|
|
204
|
+
rc, mw, out = _drive(
|
|
205
|
+
["myrepo", "existing-track"], vis="PRIVATE", target_path_exists=True
|
|
206
|
+
)
|
|
207
|
+
self.assertEqual(rc, 2)
|
|
208
|
+
mw.assert_not_called()
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Unknown repo → rc 1
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def test_unknown_repo_not_key_no_slash_returns_rc1(self):
|
|
215
|
+
"""Repo not a config key and no slash → rc 1, no write."""
|
|
216
|
+
rc, mw, out = _drive(["unknown-repo", "my-feature"])
|
|
217
|
+
self.assertEqual(rc, 1)
|
|
218
|
+
mw.assert_not_called()
|
|
219
|
+
self.assertIn("unknown repo", out.lower())
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Invalid slug → rc 2
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def test_invalid_slug_uppercase_returns_rc2(self):
|
|
226
|
+
"""Slug with uppercase letters → rc 2."""
|
|
227
|
+
rc, mw, out = _drive(["myrepo", "MyFeature"])
|
|
228
|
+
self.assertEqual(rc, 2)
|
|
229
|
+
mw.assert_not_called()
|
|
230
|
+
|
|
231
|
+
def test_invalid_slug_spaces_returns_rc2(self):
|
|
232
|
+
"""Slug with spaces → rc 2."""
|
|
233
|
+
rc, mw, out = _drive(["myrepo", "my feature"])
|
|
234
|
+
self.assertEqual(rc, 2)
|
|
235
|
+
mw.assert_not_called()
|
|
236
|
+
|
|
237
|
+
def test_invalid_slug_special_chars_returns_rc2(self):
|
|
238
|
+
"""Slug with special chars (underscore) → rc 2."""
|
|
239
|
+
rc, mw, out = _drive(["myrepo", "my_feature"])
|
|
240
|
+
self.assertEqual(rc, 2)
|
|
241
|
+
mw.assert_not_called()
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# Missing positionals → rc 2
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def test_no_args_returns_rc2(self):
|
|
248
|
+
"""No positional args at all → rc 2 (usage error)."""
|
|
249
|
+
rc, mw, out = _drive([])
|
|
250
|
+
self.assertEqual(rc, 2)
|
|
251
|
+
mw.assert_not_called()
|
|
252
|
+
|
|
253
|
+
def test_only_one_positional_returns_rc2(self):
|
|
254
|
+
"""Only repo, no slug → rc 2 (usage error)."""
|
|
255
|
+
rc, mw, out = _drive(["myrepo"])
|
|
256
|
+
self.assertEqual(rc, 2)
|
|
257
|
+
mw.assert_not_called()
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Priority and milestone flags
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def test_explicit_priority_and_milestone_in_meta(self):
|
|
264
|
+
"""--priority=P1 --milestone=v2 → reflected in meta."""
|
|
265
|
+
rc, mw, out = _drive(
|
|
266
|
+
["myrepo", "my-feature", "--priority=P1", "--milestone=v2"],
|
|
267
|
+
vis="PRIVATE"
|
|
268
|
+
)
|
|
269
|
+
self.assertEqual(rc, 0)
|
|
270
|
+
mw.assert_called_once()
|
|
271
|
+
meta = mw.call_args[0][1]
|
|
272
|
+
self.assertEqual(meta["launch_priority"], "P1")
|
|
273
|
+
self.assertEqual(meta["milestone_alignment"], "v2")
|
|
274
|
+
|
|
275
|
+
def test_defaults_p2_and_v100_when_absent(self):
|
|
276
|
+
"""No flags → launch_priority=P2, milestone_alignment=v1.0.0."""
|
|
277
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
278
|
+
self.assertEqual(rc, 0)
|
|
279
|
+
meta = mw.call_args[0][1]
|
|
280
|
+
self.assertEqual(meta["launch_priority"], "P2")
|
|
281
|
+
self.assertEqual(meta["milestone_alignment"], "v1.0.0")
|
|
282
|
+
|
|
283
|
+
def test_invalid_priority_falls_back_to_p2(self):
|
|
284
|
+
"""Invalid --priority=P9 → silently falls back to P2."""
|
|
285
|
+
rc, mw, out = _drive(
|
|
286
|
+
["myrepo", "my-feature", "--priority=P9"], vis="PRIVATE"
|
|
287
|
+
)
|
|
288
|
+
self.assertEqual(rc, 0)
|
|
289
|
+
meta = mw.call_args[0][1]
|
|
290
|
+
self.assertEqual(meta["launch_priority"], "P2")
|
|
291
|
+
|
|
292
|
+
def test_priority_uppercased(self):
|
|
293
|
+
"""--priority=p1 (lowercase) → P1 after uppercasing."""
|
|
294
|
+
rc, mw, out = _drive(
|
|
295
|
+
["myrepo", "my-feature", "--priority=p1"], vis="PRIVATE"
|
|
296
|
+
)
|
|
297
|
+
self.assertEqual(rc, 0)
|
|
298
|
+
meta = mw.call_args[0][1]
|
|
299
|
+
self.assertEqual(meta["launch_priority"], "P1")
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# Frontmatter structure
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def test_meta_has_expected_keys(self):
|
|
306
|
+
"""Written meta contains all required frontmatter keys."""
|
|
307
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
308
|
+
self.assertEqual(rc, 0)
|
|
309
|
+
meta = mw.call_args[0][1]
|
|
310
|
+
for key in ("track", "status", "launch_priority", "milestone_alignment",
|
|
311
|
+
"github", "related_tracks", "last_touched", "last_handoff",
|
|
312
|
+
"next_up", "blockers"):
|
|
313
|
+
self.assertIn(key, meta, f"meta missing key: {key}")
|
|
314
|
+
|
|
315
|
+
def test_meta_github_issues_and_branches_empty(self):
|
|
316
|
+
"""New track starts with github.issues=[] and github.branches=[]."""
|
|
317
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
318
|
+
self.assertEqual(rc, 0)
|
|
319
|
+
meta = mw.call_args[0][1]
|
|
320
|
+
self.assertEqual(meta["github"]["issues"], [])
|
|
321
|
+
self.assertEqual(meta["github"]["branches"], [])
|
|
322
|
+
|
|
323
|
+
def test_meta_related_tracks_next_up_blockers_empty(self):
|
|
324
|
+
"""New track starts with empty related_tracks, next_up, blockers."""
|
|
325
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
326
|
+
self.assertEqual(rc, 0)
|
|
327
|
+
meta = mw.call_args[0][1]
|
|
328
|
+
self.assertEqual(meta["related_tracks"], [])
|
|
329
|
+
self.assertEqual(meta["next_up"], [])
|
|
330
|
+
self.assertEqual(meta["blockers"], [])
|
|
331
|
+
|
|
332
|
+
def test_body_contains_slug_heading(self):
|
|
333
|
+
"""Body passed to write_file contains a heading with the slug."""
|
|
334
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
335
|
+
self.assertEqual(rc, 0)
|
|
336
|
+
body = mw.call_args[0][2]
|
|
337
|
+
self.assertIn("my-feature", body)
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# --private flag (no-op, accepted without error)
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def test_private_flag_accepted_no_error(self):
|
|
344
|
+
"""--private accepted without error, creates normally, rc 0."""
|
|
345
|
+
rc, mw, out = _drive(
|
|
346
|
+
["myrepo", "my-feature", "--private"], vis="PRIVATE"
|
|
347
|
+
)
|
|
348
|
+
self.assertEqual(rc, 0)
|
|
349
|
+
mw.assert_called_once()
|
|
350
|
+
|
|
351
|
+
def test_private_flag_on_public_repo_still_gated(self):
|
|
352
|
+
"""--private on a public repo: confirm gate still fires (gate is by
|
|
353
|
+
visibility, not --private flag)."""
|
|
354
|
+
rc, mw, out = _drive(
|
|
355
|
+
["myrepo", "my-feature", "--private"], vis="PUBLIC"
|
|
356
|
+
)
|
|
357
|
+
self.assertEqual(rc, 0)
|
|
358
|
+
mw.assert_not_called()
|
|
359
|
+
data = json.loads(out.strip())
|
|
360
|
+
self.assertTrue(data["needs_confirm"])
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
# notes_root missing → rc 1
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def test_missing_notes_root_returns_rc1(self):
|
|
367
|
+
"""notes_root directory does not exist → rc 1, no write."""
|
|
368
|
+
rc, mw, out = _drive(
|
|
369
|
+
["myrepo", "my-feature"], vis="PRIVATE", notes_root_exists=False
|
|
370
|
+
)
|
|
371
|
+
self.assertEqual(rc, 1)
|
|
372
|
+
mw.assert_not_called()
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------------
|
|
375
|
+
# Success output
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def test_success_prints_created_line(self):
|
|
379
|
+
"""On success, output contains 'Created track' and the slug."""
|
|
380
|
+
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
381
|
+
self.assertEqual(rc, 0)
|
|
382
|
+
self.assertIn("my-feature", out)
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------
|
|
385
|
+
# Gate fires BEFORE any FS write
|
|
386
|
+
# ------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def test_gate_before_mkdir_on_public(self):
|
|
389
|
+
"""On a public repo without a token, mkdir is NOT called
|
|
390
|
+
(gate fires before any filesystem operation)."""
|
|
391
|
+
cfg = _make_cfg()
|
|
392
|
+
|
|
393
|
+
def _path_exists(self):
|
|
394
|
+
if self == Path(NOTES_ROOT):
|
|
395
|
+
return True
|
|
396
|
+
if self.suffix == ".md":
|
|
397
|
+
return False
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
401
|
+
patch("commands.new_track.write_file") as mw, \
|
|
402
|
+
patch("lib.write_guard.repo_visibility", return_value="PUBLIC"), \
|
|
403
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
404
|
+
patch("pathlib.Path.mkdir") as mmkdir:
|
|
405
|
+
buf = io.StringIO()
|
|
406
|
+
with redirect_stdout(buf):
|
|
407
|
+
rc = new_track.run(["myrepo", "my-feature"])
|
|
408
|
+
self.assertEqual(rc, 0)
|
|
409
|
+
mw.assert_not_called()
|
|
410
|
+
mmkdir.assert_not_called()
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# No input() on non-interactive paths
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def test_no_input_called_on_private_repo(self):
|
|
417
|
+
"""Private repo with valid flags never calls input() or prompt_input —
|
|
418
|
+
proving no prompt is hit on the non-interactive code path."""
|
|
419
|
+
cfg = _make_cfg()
|
|
420
|
+
|
|
421
|
+
def _raise(*a, **kw):
|
|
422
|
+
raise AssertionError("input() must not be called — command must be non-interactive")
|
|
423
|
+
|
|
424
|
+
def _path_exists(self):
|
|
425
|
+
if self == Path(NOTES_ROOT):
|
|
426
|
+
return True
|
|
427
|
+
if self.suffix == ".md":
|
|
428
|
+
return False
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
432
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
433
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
434
|
+
patch("commands.new_track.write_file") as mw, \
|
|
435
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
436
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
437
|
+
patch("pathlib.Path.mkdir"):
|
|
438
|
+
buf = io.StringIO()
|
|
439
|
+
with redirect_stdout(buf):
|
|
440
|
+
rc = new_track.run(["myrepo", "my-feature"])
|
|
441
|
+
self.assertEqual(rc, 0)
|
|
442
|
+
mw.assert_called_once()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Phase D: --commit flag tests
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
CLONE_ROOT = "/tmp/fake-clone"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _make_cfg_with_local(*, local=CLONE_ROOT):
|
|
453
|
+
"""Config with a repo entry that has a local clone path."""
|
|
454
|
+
return {
|
|
455
|
+
"notes_root": NOTES_ROOT,
|
|
456
|
+
"repos": {
|
|
457
|
+
"myrepo": {"github": "org/myrepo", "local": local},
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class NewTrackCommitFlagTest(unittest.TestCase):
|
|
463
|
+
"""Tests for --commit flag on new-track (Phase D)."""
|
|
464
|
+
|
|
465
|
+
def _drive_shared(self, args, *, git_returncode=0, path_exists=False):
|
|
466
|
+
"""Drive new-track with a shared-tier setup (local clone is a valid git repo)."""
|
|
467
|
+
cfg = _make_cfg_with_local()
|
|
468
|
+
|
|
469
|
+
def _path_exists(self):
|
|
470
|
+
# NOTES_ROOT itself exists
|
|
471
|
+
if self == Path(NOTES_ROOT):
|
|
472
|
+
return True
|
|
473
|
+
# .git dir inside the clone root exists (valid git repo)
|
|
474
|
+
if str(self) == f"{CLONE_ROOT}/.git":
|
|
475
|
+
return True
|
|
476
|
+
# The clone root itself exists
|
|
477
|
+
if str(self) == CLONE_ROOT:
|
|
478
|
+
return True
|
|
479
|
+
# The target .md path: controlled by path_exists
|
|
480
|
+
if self.suffix == ".md":
|
|
481
|
+
return path_exists
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
def _is_dir(self):
|
|
485
|
+
s = str(self)
|
|
486
|
+
if s.endswith(".md"):
|
|
487
|
+
return False
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
# git subprocess: first call (rev-parse), then add, then commit
|
|
491
|
+
git_results = [
|
|
492
|
+
MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse
|
|
493
|
+
MagicMock(returncode=git_returncode, stdout="", stderr="error msg"), # add
|
|
494
|
+
MagicMock(returncode=git_returncode, stdout="", stderr=""), # commit
|
|
495
|
+
]
|
|
496
|
+
git_call_index = {"n": 0}
|
|
497
|
+
|
|
498
|
+
def _git_run(cmd, **kwargs):
|
|
499
|
+
idx = git_call_index["n"]
|
|
500
|
+
git_call_index["n"] += 1
|
|
501
|
+
if git_returncode != 0 and idx > 0:
|
|
502
|
+
raise subprocess.CalledProcessError(git_returncode, cmd, stderr="error msg")
|
|
503
|
+
return git_results[min(idx, len(git_results) - 1)]
|
|
504
|
+
|
|
505
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
506
|
+
patch("commands.new_track.write_file") as mw, \
|
|
507
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
508
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
509
|
+
patch("pathlib.Path.is_dir", _is_dir), \
|
|
510
|
+
patch("pathlib.Path.mkdir"), \
|
|
511
|
+
patch("commands.new_track.subprocess.run", side_effect=_git_run) as msub:
|
|
512
|
+
buf = io.StringIO()
|
|
513
|
+
with redirect_stdout(buf):
|
|
514
|
+
rc = new_track.run(args)
|
|
515
|
+
return rc, mw, msub, buf.getvalue()
|
|
516
|
+
|
|
517
|
+
def test_commit_shared_track_calls_git_add_then_commit(self):
|
|
518
|
+
"""--commit on a shared track: git -C <clone_root> add <file> called,
|
|
519
|
+
then git -C <clone_root> commit called; path-scoped (not git add .)."""
|
|
520
|
+
rc, mw, msub, out = self._drive_shared(
|
|
521
|
+
["myrepo", "my-feature", "--commit"]
|
|
522
|
+
)
|
|
523
|
+
self.assertEqual(rc, 0)
|
|
524
|
+
mw.assert_called_once()
|
|
525
|
+
# Should have made git calls: rev-parse, add, commit
|
|
526
|
+
calls = msub.call_args_list
|
|
527
|
+
# Find the add and commit calls (skip rev-parse at index 0)
|
|
528
|
+
git_cmds = [c[0][0] for c in calls]
|
|
529
|
+
add_calls = [c for c in git_cmds if "add" in c]
|
|
530
|
+
commit_calls = [c for c in git_cmds if "commit" in c]
|
|
531
|
+
self.assertEqual(len(add_calls), 1, "exactly one git add call expected")
|
|
532
|
+
self.assertEqual(len(commit_calls), 1, "exactly one git commit call expected")
|
|
533
|
+
# Verify add is path-scoped (not "git add .")
|
|
534
|
+
add_argv = add_calls[0]
|
|
535
|
+
self.assertNotIn(".", add_argv, "git add must be path-scoped, not 'git add .'")
|
|
536
|
+
self.assertIn("-C", add_argv)
|
|
537
|
+
# The file argument should end in .md
|
|
538
|
+
file_arg = add_argv[-1]
|
|
539
|
+
self.assertTrue(file_arg.endswith(".md"), f"expected .md path, got: {file_arg}")
|
|
540
|
+
# Commit message should mention the slug
|
|
541
|
+
commit_argv = commit_calls[0]
|
|
542
|
+
msg_idx = commit_argv.index("-m") + 1
|
|
543
|
+
self.assertIn("my-feature", commit_argv[msg_idx])
|
|
544
|
+
|
|
545
|
+
def test_commit_shared_track_path_scoped_not_git_add_dot(self):
|
|
546
|
+
"""The git add call must never use '.' as the file argument."""
|
|
547
|
+
rc, mw, msub, out = self._drive_shared(
|
|
548
|
+
["myrepo", "path-scoped-test", "--commit"]
|
|
549
|
+
)
|
|
550
|
+
self.assertEqual(rc, 0)
|
|
551
|
+
git_cmds = [c[0][0] for c in msub.call_args_list]
|
|
552
|
+
add_calls = [c for c in git_cmds if "add" in c]
|
|
553
|
+
self.assertEqual(len(add_calls), 1)
|
|
554
|
+
self.assertNotIn(".", add_calls[0])
|
|
555
|
+
|
|
556
|
+
def test_commit_private_track_warns_and_skips_git(self):
|
|
557
|
+
"""--commit on a private track (notes_root, not .work-plan) → warning
|
|
558
|
+
printed, git NOT called."""
|
|
559
|
+
cfg = _make_cfg() # no local clone → private route
|
|
560
|
+
|
|
561
|
+
def _path_exists(self):
|
|
562
|
+
if self == Path(NOTES_ROOT):
|
|
563
|
+
return True
|
|
564
|
+
if self.suffix == ".md":
|
|
565
|
+
return False
|
|
566
|
+
return True
|
|
567
|
+
|
|
568
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
569
|
+
patch("commands.new_track.write_file") as mw, \
|
|
570
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
571
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
572
|
+
patch("pathlib.Path.mkdir"), \
|
|
573
|
+
patch("commands.new_track.subprocess.run") as msub:
|
|
574
|
+
buf = io.StringIO()
|
|
575
|
+
with redirect_stdout(buf):
|
|
576
|
+
rc = new_track.run(["myrepo", "private-track", "--commit"])
|
|
577
|
+
self.assertEqual(rc, 0)
|
|
578
|
+
mw.assert_called_once()
|
|
579
|
+
msub.assert_not_called()
|
|
580
|
+
self.assertIn("--commit ignored", buf.getvalue())
|
|
581
|
+
|
|
582
|
+
def test_commit_git_failure_is_non_fatal(self):
|
|
583
|
+
"""--commit with git add failing → rc still 0, warning printed."""
|
|
584
|
+
rc, mw, msub, out = self._drive_shared(
|
|
585
|
+
["myrepo", "my-feature", "--commit"],
|
|
586
|
+
git_returncode=1,
|
|
587
|
+
)
|
|
588
|
+
self.assertEqual(rc, 0, "git failure must be non-fatal")
|
|
589
|
+
mw.assert_called_once()
|
|
590
|
+
self.assertIn("⚠", out)
|
|
591
|
+
|
|
592
|
+
def test_no_commit_flag_no_git_calls(self):
|
|
593
|
+
"""Without --commit: git is never called, even for a shared track."""
|
|
594
|
+
rc, mw, msub, out = self._drive_shared(["myrepo", "my-feature"])
|
|
595
|
+
self.assertEqual(rc, 0)
|
|
596
|
+
mw.assert_called_once()
|
|
597
|
+
msub.assert_not_called()
|
|
598
|
+
|
|
599
|
+
def test_commit_success_prints_committed_line(self):
|
|
600
|
+
"""Successful --commit → output contains 'committed' and the slug."""
|
|
601
|
+
rc, mw, msub, out = self._drive_shared(
|
|
602
|
+
["myrepo", "my-feature", "--commit"]
|
|
603
|
+
)
|
|
604
|
+
self.assertEqual(rc, 0)
|
|
605
|
+
self.assertIn("committed", out)
|
|
606
|
+
self.assertIn("my-feature", out)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
if __name__ == "__main__":
|
|
610
|
+
unittest.main()
|