@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,289 @@
|
|
|
1
|
+
"""Tests for the non-interactive init command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Writes frontmatter with --priority=P1 --milestone=v2 (meta reflects them).
|
|
5
|
+
- Defaults P2/v1.0.0 when flags absent.
|
|
6
|
+
- Invalid --priority=P9 → falls back to P2.
|
|
7
|
+
- Body '#41 #88' refs become github.issues:[41,88].
|
|
8
|
+
- 'Already has frontmatter' → no write, rc 0.
|
|
9
|
+
- Public real repo, no token → needs_confirm JSON, no write, rc 0;
|
|
10
|
+
token == make_token(repo, slug).
|
|
11
|
+
- TBD/unknown repo → NO gate, writes normally (local-only case).
|
|
12
|
+
- Valid --confirm on a public repo → writes.
|
|
13
|
+
- No input()/prompt_input reached (patch to raise).
|
|
14
|
+
"""
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import unittest
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from unittest.mock import patch, MagicMock
|
|
23
|
+
|
|
24
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
25
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
26
|
+
|
|
27
|
+
from commands import init
|
|
28
|
+
from lib.write_guard import make_token
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
|
|
36
|
+
if repos is None:
|
|
37
|
+
repos = {"myrepo": {"github": "org/myrepo", "local": None}}
|
|
38
|
+
return {"notes_root": notes_root, "repos": repos}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _drive(args, *, meta=None, body="", repo=None, slug="my-track", vis="PRIVATE"):
|
|
42
|
+
"""Run init.run(args) with all external I/O mocked.
|
|
43
|
+
|
|
44
|
+
meta: what parse_file returns as existing frontmatter (None → {} = no fm).
|
|
45
|
+
body: what parse_file returns as the body text.
|
|
46
|
+
repo: the resolved github repo for the file's folder (None → unknown/TBD).
|
|
47
|
+
slug: the slug derived from path stem.
|
|
48
|
+
vis: what repo_visibility returns.
|
|
49
|
+
"""
|
|
50
|
+
existing_meta = meta if meta is not None else {}
|
|
51
|
+
fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
|
|
52
|
+
cfg = _make_cfg()
|
|
53
|
+
|
|
54
|
+
# We patch Path.exists to return True so the "file not found" check passes,
|
|
55
|
+
# Path.stat, and parse_file / write_file to avoid real I/O.
|
|
56
|
+
with patch("commands.init.load_config", return_value=cfg), \
|
|
57
|
+
patch("commands.init.parse_file", return_value=(existing_meta, body)), \
|
|
58
|
+
patch("commands.init.write_file") as mw, \
|
|
59
|
+
patch("commands.init.resolve_github_for_folder", return_value=repo), \
|
|
60
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
61
|
+
patch("pathlib.Path.exists", return_value=True), \
|
|
62
|
+
patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
|
|
63
|
+
# Build args: prepend fake path as first positional
|
|
64
|
+
full_args = [str(fake_path)] + list(args)
|
|
65
|
+
buf = io.StringIO()
|
|
66
|
+
with redirect_stdout(buf):
|
|
67
|
+
rc = init.run(full_args)
|
|
68
|
+
return rc, mw, buf.getvalue()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Test cases
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
class InitNonInteractiveTest(unittest.TestCase):
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Flag-driven priority and milestone
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def test_writes_with_explicit_priority_and_milestone(self):
|
|
82
|
+
"""--priority=P1 --milestone=v2 → meta has launch_priority=P1,
|
|
83
|
+
milestone_alignment=v2; write_file called; rc 0."""
|
|
84
|
+
rc, mw, out = _drive(["--priority=P1", "--milestone=v2"], repo="org/myrepo")
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
mw.assert_called_once()
|
|
87
|
+
written_meta = mw.call_args[0][1]
|
|
88
|
+
self.assertEqual(written_meta["launch_priority"], "P1")
|
|
89
|
+
self.assertEqual(written_meta["milestone_alignment"], "v2")
|
|
90
|
+
|
|
91
|
+
def test_defaults_p2_and_v100_when_flags_absent(self):
|
|
92
|
+
"""No flags → launch_priority defaults to P2, milestone to v1.0.0."""
|
|
93
|
+
rc, mw, out = _drive([], repo="org/myrepo", vis="PRIVATE")
|
|
94
|
+
self.assertEqual(rc, 0)
|
|
95
|
+
mw.assert_called_once()
|
|
96
|
+
written_meta = mw.call_args[0][1]
|
|
97
|
+
self.assertEqual(written_meta["launch_priority"], "P2")
|
|
98
|
+
self.assertEqual(written_meta["milestone_alignment"], "v1.0.0")
|
|
99
|
+
|
|
100
|
+
def test_invalid_priority_falls_back_to_p2(self):
|
|
101
|
+
"""--priority=P9 (invalid) → launch_priority silently falls back to P2."""
|
|
102
|
+
rc, mw, out = _drive(["--priority=P9"], repo="org/myrepo", vis="PRIVATE")
|
|
103
|
+
self.assertEqual(rc, 0)
|
|
104
|
+
mw.assert_called_once()
|
|
105
|
+
written_meta = mw.call_args[0][1]
|
|
106
|
+
self.assertEqual(written_meta["launch_priority"], "P2")
|
|
107
|
+
|
|
108
|
+
def test_priority_uppercased(self):
|
|
109
|
+
"""--priority=p1 (lowercase) → P1 after uppercasing."""
|
|
110
|
+
rc, mw, out = _drive(["--priority=p1"], repo="org/myrepo", vis="PRIVATE")
|
|
111
|
+
self.assertEqual(rc, 0)
|
|
112
|
+
mw.assert_called_once()
|
|
113
|
+
written_meta = mw.call_args[0][1]
|
|
114
|
+
self.assertEqual(written_meta["launch_priority"], "P1")
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Issue ref scanning
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def test_body_issue_refs_become_github_issues(self):
|
|
121
|
+
"""Body '#41 #88' → github.issues == [41, 88] (sorted)."""
|
|
122
|
+
body = "See #88 and also #41 for details.\n"
|
|
123
|
+
rc, mw, out = _drive([], body=body, repo="org/myrepo", vis="PRIVATE")
|
|
124
|
+
self.assertEqual(rc, 0)
|
|
125
|
+
mw.assert_called_once()
|
|
126
|
+
written_meta = mw.call_args[0][1]
|
|
127
|
+
self.assertEqual(written_meta["github"]["issues"], [41, 88])
|
|
128
|
+
|
|
129
|
+
def test_no_issue_refs_gives_empty_list(self):
|
|
130
|
+
"""Body with no #N refs → github.issues == []."""
|
|
131
|
+
rc, mw, out = _drive([], body="No refs here.\n", repo="org/myrepo", vis="PRIVATE")
|
|
132
|
+
self.assertEqual(rc, 0)
|
|
133
|
+
written_meta = mw.call_args[0][1]
|
|
134
|
+
self.assertEqual(written_meta["github"]["issues"], [])
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Already has frontmatter → no-op
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def test_already_has_frontmatter_no_write_rc0(self):
|
|
141
|
+
"""File already has frontmatter → no write, rc 0."""
|
|
142
|
+
existing_meta = {"track": "my-track", "status": "active"}
|
|
143
|
+
rc, mw, out = _drive([], meta=existing_meta, repo="org/myrepo", vis="PRIVATE")
|
|
144
|
+
self.assertEqual(rc, 0)
|
|
145
|
+
mw.assert_not_called()
|
|
146
|
+
self.assertIn("already has frontmatter", out)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Repo resolution in written meta
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def test_tbd_repo_when_folder_unknown(self):
|
|
153
|
+
"""No repo resolved for folder → meta.github.repo == 'TBD'."""
|
|
154
|
+
rc, mw, out = _drive([], repo=None, vis="PRIVATE")
|
|
155
|
+
self.assertEqual(rc, 0)
|
|
156
|
+
mw.assert_called_once()
|
|
157
|
+
written_meta = mw.call_args[0][1]
|
|
158
|
+
self.assertEqual(written_meta["github"]["repo"], "TBD")
|
|
159
|
+
|
|
160
|
+
def test_resolved_repo_appears_in_meta(self):
|
|
161
|
+
"""Resolved repo → meta.github.repo == 'org/myrepo'."""
|
|
162
|
+
rc, mw, out = _drive([], repo="org/myrepo", vis="PRIVATE")
|
|
163
|
+
self.assertEqual(rc, 0)
|
|
164
|
+
written_meta = mw.call_args[0][1]
|
|
165
|
+
self.assertEqual(written_meta["github"]["repo"], "org/myrepo")
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Confirm-token gate — public repo
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
172
|
+
"""Public repo, no token → prints needs_confirm JSON, no write, rc 0;
|
|
173
|
+
token equals make_token(repo, slug)."""
|
|
174
|
+
rc, mw, out = _drive([], repo="org/myrepo", vis="PUBLIC")
|
|
175
|
+
self.assertEqual(rc, 0)
|
|
176
|
+
mw.assert_not_called()
|
|
177
|
+
data = json.loads(out.strip())
|
|
178
|
+
self.assertTrue(data["needs_confirm"])
|
|
179
|
+
# Slug is derived from 'my-track.md' stem → 'my-track'
|
|
180
|
+
self.assertEqual(data["token"], make_token("org/myrepo", "my-track"))
|
|
181
|
+
|
|
182
|
+
def test_unknown_visibility_returns_needs_confirm_json(self):
|
|
183
|
+
"""Unknown visibility (None) → also requires confirm."""
|
|
184
|
+
rc, mw, out = _drive([], repo="org/myrepo", vis=None)
|
|
185
|
+
self.assertEqual(rc, 0)
|
|
186
|
+
mw.assert_not_called()
|
|
187
|
+
data = json.loads(out.strip())
|
|
188
|
+
self.assertTrue(data["needs_confirm"])
|
|
189
|
+
|
|
190
|
+
def test_public_repo_wrong_token_blocks_write(self):
|
|
191
|
+
"""Wrong confirm token → blocked, no write, rc 0."""
|
|
192
|
+
rc, mw, out = _drive(["--confirm=wrongtoken"], repo="org/myrepo", vis="PUBLIC")
|
|
193
|
+
self.assertEqual(rc, 0)
|
|
194
|
+
mw.assert_not_called()
|
|
195
|
+
data = json.loads(out.strip())
|
|
196
|
+
self.assertTrue(data["needs_confirm"])
|
|
197
|
+
|
|
198
|
+
def test_public_repo_valid_token_writes(self):
|
|
199
|
+
"""Valid --confirm=<token> on a public repo → writes, rc 0."""
|
|
200
|
+
tok = make_token("org/myrepo", "my-track")
|
|
201
|
+
rc, mw, out = _drive([f"--confirm={tok}"], repo="org/myrepo", vis="PUBLIC")
|
|
202
|
+
self.assertEqual(rc, 0)
|
|
203
|
+
mw.assert_called_once()
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# TBD/unknown repo → NO confirm gate (local-only case)
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def test_tbd_repo_skips_confirm_gate_and_writes(self):
|
|
210
|
+
"""repo is None (→ TBD) → confirm gate is skipped entirely, writes normally."""
|
|
211
|
+
# Even with vis=PUBLIC, if repo is None there's no gate
|
|
212
|
+
rc, mw, out = _drive([], repo=None, vis="PUBLIC")
|
|
213
|
+
self.assertEqual(rc, 0)
|
|
214
|
+
mw.assert_called_once()
|
|
215
|
+
written_meta = mw.call_args[0][1]
|
|
216
|
+
self.assertEqual(written_meta["github"]["repo"], "TBD")
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# File not found → rc 1
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def test_file_not_found_returns_rc1(self):
|
|
223
|
+
"""Missing file → rc 1."""
|
|
224
|
+
cfg = _make_cfg()
|
|
225
|
+
with patch("commands.init.load_config", return_value=cfg), \
|
|
226
|
+
patch("pathlib.Path.exists", return_value=False):
|
|
227
|
+
buf = io.StringIO()
|
|
228
|
+
with redirect_stdout(buf):
|
|
229
|
+
rc = init.run(["/tmp/no-such-file.md"])
|
|
230
|
+
self.assertEqual(rc, 1)
|
|
231
|
+
self.assertIn("ERROR", buf.getvalue())
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# No input()/prompt_input on any path
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def test_no_input_called_on_flagged_path(self):
|
|
238
|
+
"""Flagged path never calls input() or prompt_input, even with all flags."""
|
|
239
|
+
fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
|
|
240
|
+
cfg = _make_cfg()
|
|
241
|
+
tok = make_token("org/myrepo", "my-track")
|
|
242
|
+
|
|
243
|
+
def _raise(*a, **kw):
|
|
244
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
245
|
+
|
|
246
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
247
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
248
|
+
with patch("commands.init.load_config", return_value=cfg), \
|
|
249
|
+
patch("commands.init.parse_file", return_value=({}, "")), \
|
|
250
|
+
patch("commands.init.write_file"), \
|
|
251
|
+
patch("commands.init.resolve_github_for_folder", return_value="org/myrepo"), \
|
|
252
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
253
|
+
patch("pathlib.Path.exists", return_value=True), \
|
|
254
|
+
patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
|
|
255
|
+
buf = io.StringIO()
|
|
256
|
+
with redirect_stdout(buf):
|
|
257
|
+
rc = init.run([
|
|
258
|
+
str(fake_path),
|
|
259
|
+
"--priority=P1",
|
|
260
|
+
"--milestone=v2",
|
|
261
|
+
f"--confirm={tok}",
|
|
262
|
+
])
|
|
263
|
+
self.assertEqual(rc, 0)
|
|
264
|
+
|
|
265
|
+
def test_no_input_called_when_no_flags(self):
|
|
266
|
+
"""No flags on a private repo → still no input() call."""
|
|
267
|
+
fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
|
|
268
|
+
cfg = _make_cfg()
|
|
269
|
+
|
|
270
|
+
def _raise(*a, **kw):
|
|
271
|
+
raise AssertionError("input() must not be called — command must be non-interactive")
|
|
272
|
+
|
|
273
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
274
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
275
|
+
with patch("commands.init.load_config", return_value=cfg), \
|
|
276
|
+
patch("commands.init.parse_file", return_value=({}, "")), \
|
|
277
|
+
patch("commands.init.write_file"), \
|
|
278
|
+
patch("commands.init.resolve_github_for_folder", return_value="org/myrepo"), \
|
|
279
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
280
|
+
patch("pathlib.Path.exists", return_value=True), \
|
|
281
|
+
patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
|
|
282
|
+
buf = io.StringIO()
|
|
283
|
+
with redirect_stdout(buf):
|
|
284
|
+
rc = init.run([str(fake_path)])
|
|
285
|
+
self.assertEqual(rc, 0)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
unittest.main()
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Tests for the non-interactive init-repo command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- --github=org/repo --local=/some/path → yq subprocess called with correct
|
|
5
|
+
expression + folders created (mocked); rc 0.
|
|
6
|
+
- --github only (no --local) → works, local omitted from the block; rc 0.
|
|
7
|
+
- Missing --github → rc 2, no yq call, no prompt.
|
|
8
|
+
- Invalid github (no slash) → rc 2.
|
|
9
|
+
- 'repo already exists' → rc 1.
|
|
10
|
+
- No input()/prompt_input reached (patch to raise).
|
|
11
|
+
"""
|
|
12
|
+
import io
|
|
13
|
+
import sys
|
|
14
|
+
import unittest
|
|
15
|
+
from contextlib import redirect_stdout
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
from unittest.mock import patch, MagicMock, call
|
|
19
|
+
|
|
20
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
22
|
+
|
|
23
|
+
from commands import init_repo
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Helpers
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
|
|
31
|
+
if repos is None:
|
|
32
|
+
repos = {}
|
|
33
|
+
return {"notes_root": notes_root, "repos": repos}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _drive(args, *, existing_repos=None, notes_root_exists=True, vis="PRIVATE"):
|
|
37
|
+
"""Run init_repo.run(args) with all external I/O mocked.
|
|
38
|
+
|
|
39
|
+
existing_repos: dict of repos already in config (default empty).
|
|
40
|
+
notes_root_exists: whether notes_root directory exists.
|
|
41
|
+
"""
|
|
42
|
+
cfg = _make_cfg(repos=existing_repos or {})
|
|
43
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
44
|
+
|
|
45
|
+
# We need notes_root to 'exist' and notes_root / key dirs created
|
|
46
|
+
def _path_exists(self):
|
|
47
|
+
# notes_root itself exists; repo subdirs may not — that's fine for mkdir
|
|
48
|
+
return notes_root_exists
|
|
49
|
+
|
|
50
|
+
with patch("commands.init_repo.load_config", return_value=cfg), \
|
|
51
|
+
patch("commands.init_repo.subprocess.run", return_value=mock_proc) as msub, \
|
|
52
|
+
patch("pathlib.Path.exists", return_value=notes_root_exists), \
|
|
53
|
+
patch("pathlib.Path.mkdir"), \
|
|
54
|
+
patch("pathlib.Path.touch"):
|
|
55
|
+
buf = io.StringIO()
|
|
56
|
+
with redirect_stdout(buf):
|
|
57
|
+
rc = init_repo.run(args)
|
|
58
|
+
return rc, msub, buf.getvalue()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Test cases
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Happy path: --github + --local
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def test_github_and_local_writes_yq_and_creates_folders(self):
|
|
72
|
+
"""--github=org/repo --local=/some/path → yq called with the right
|
|
73
|
+
expression and folder creation happens; rc 0."""
|
|
74
|
+
rc, msub, out = _drive(
|
|
75
|
+
["mykey", "--github=org/myrepo", "--local=/some/path"],
|
|
76
|
+
)
|
|
77
|
+
self.assertEqual(rc, 0)
|
|
78
|
+
# yq subprocess should have been called
|
|
79
|
+
msub.assert_called_once()
|
|
80
|
+
yq_args = msub.call_args[0][0] # positional first arg is the argv list
|
|
81
|
+
self.assertEqual(yq_args[0], "yq")
|
|
82
|
+
self.assertEqual(yq_args[1], "-i")
|
|
83
|
+
# Expression should contain both github and local
|
|
84
|
+
expr = yq_args[2]
|
|
85
|
+
self.assertIn("org/myrepo", expr)
|
|
86
|
+
self.assertIn("/some/path", expr)
|
|
87
|
+
self.assertIn("mykey", expr)
|
|
88
|
+
self.assertIn("✓", out)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Happy path: --github only (no --local)
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def test_github_only_omits_local_from_block(self):
|
|
95
|
+
"""--github=org/repo, no --local → local key absent from yq expression;
|
|
96
|
+
rc 0."""
|
|
97
|
+
rc, msub, out = _drive(
|
|
98
|
+
["mykey", "--github=org/myrepo"],
|
|
99
|
+
)
|
|
100
|
+
self.assertEqual(rc, 0)
|
|
101
|
+
msub.assert_called_once()
|
|
102
|
+
expr = msub.call_args[0][0][2]
|
|
103
|
+
self.assertIn("org/myrepo", expr)
|
|
104
|
+
# local should NOT appear in the yq expression
|
|
105
|
+
self.assertNotIn("local", expr)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Missing --github → rc 2, no yq, no prompt
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def test_missing_github_returns_rc2(self):
|
|
112
|
+
"""Missing --github → rc 2, yq NOT called."""
|
|
113
|
+
rc, msub, out = _drive(["mykey"])
|
|
114
|
+
self.assertEqual(rc, 2)
|
|
115
|
+
msub.assert_not_called()
|
|
116
|
+
self.assertIn("ERROR", out)
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Invalid github (no slash) → rc 2
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def test_invalid_github_no_slash_returns_rc2(self):
|
|
123
|
+
"""--github=noslash (no '/') → rc 2, yq NOT called."""
|
|
124
|
+
rc, msub, out = _drive(["mykey", "--github=noslash"])
|
|
125
|
+
self.assertEqual(rc, 2)
|
|
126
|
+
msub.assert_not_called()
|
|
127
|
+
self.assertIn("ERROR", out)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Repo already exists → rc 1
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def test_repo_already_exists_returns_rc1(self):
|
|
134
|
+
"""Key already in config.repos → rc 1, yq NOT called."""
|
|
135
|
+
existing = {"mykey": {"github": "org/myrepo", "local": None}}
|
|
136
|
+
rc, msub, out = _drive(["mykey", "--github=org/myrepo"], existing_repos=existing)
|
|
137
|
+
self.assertEqual(rc, 1)
|
|
138
|
+
msub.assert_not_called()
|
|
139
|
+
self.assertIn("already exists", out)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# No key → rc 2
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def test_no_key_returns_rc2(self):
|
|
146
|
+
"""No positional key at all → rc 2."""
|
|
147
|
+
rc, msub, out = _drive(["--github=org/myrepo"])
|
|
148
|
+
self.assertEqual(rc, 2)
|
|
149
|
+
msub.assert_not_called()
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Invalid key format → rc 2
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def test_invalid_key_format_returns_rc2(self):
|
|
156
|
+
"""Key with uppercase letters → rc 2."""
|
|
157
|
+
rc, msub, out = _drive(["MyKey", "--github=org/myrepo"])
|
|
158
|
+
self.assertEqual(rc, 2)
|
|
159
|
+
msub.assert_not_called()
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# notes_root does not exist → rc 1
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def test_notes_root_missing_returns_rc1(self):
|
|
166
|
+
"""notes_root dir does not exist → rc 1, yq NOT called."""
|
|
167
|
+
rc, msub, out = _drive(
|
|
168
|
+
["mykey", "--github=org/myrepo"],
|
|
169
|
+
notes_root_exists=False,
|
|
170
|
+
)
|
|
171
|
+
self.assertEqual(rc, 1)
|
|
172
|
+
msub.assert_not_called()
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Local path warn when given path doesn't exist
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def test_local_nonexistent_path_prints_warn(self):
|
|
179
|
+
"""--local=/no/such/path (not on disk) → prints WARN but continues;
|
|
180
|
+
rc 0 and yq IS called."""
|
|
181
|
+
# We keep notes_root_exists=True but we need local path check to fail.
|
|
182
|
+
# Override Path.exists to: return True for notes_root, False for local path.
|
|
183
|
+
cfg = _make_cfg(repos={})
|
|
184
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
185
|
+
|
|
186
|
+
with patch("commands.init_repo.load_config", return_value=cfg), \
|
|
187
|
+
patch("commands.init_repo.subprocess.run", return_value=mock_proc) as msub, \
|
|
188
|
+
patch("pathlib.Path.mkdir"), \
|
|
189
|
+
patch("pathlib.Path.touch"):
|
|
190
|
+
# Patch exists to return True for notes_root, False for local path
|
|
191
|
+
call_count = {"n": 0}
|
|
192
|
+
original_exists = Path.exists
|
|
193
|
+
|
|
194
|
+
def _exists(self):
|
|
195
|
+
# notes_root resolves to /tmp/fake-notes; local to /no/such/path
|
|
196
|
+
s = str(self)
|
|
197
|
+
if "fake-notes" in s:
|
|
198
|
+
return True
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
with patch("pathlib.Path.exists", _exists):
|
|
202
|
+
buf = io.StringIO()
|
|
203
|
+
with redirect_stdout(buf):
|
|
204
|
+
rc = init_repo.run(["mykey", "--github=org/myrepo", "--local=/no/such/path"])
|
|
205
|
+
self.assertEqual(rc, 0)
|
|
206
|
+
msub.assert_called_once()
|
|
207
|
+
self.assertIn("WARN", buf.getvalue())
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# No input()/prompt_input on any path
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def test_no_input_called_with_github_flag(self):
|
|
214
|
+
"""--github provided → no input() or prompt_input call."""
|
|
215
|
+
cfg = _make_cfg(repos={})
|
|
216
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
217
|
+
|
|
218
|
+
def _raise(*a, **kw):
|
|
219
|
+
raise AssertionError("input() must not be called — command must be non-interactive")
|
|
220
|
+
|
|
221
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
222
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
223
|
+
with patch("commands.init_repo.load_config", return_value=cfg), \
|
|
224
|
+
patch("commands.init_repo.subprocess.run", return_value=mock_proc), \
|
|
225
|
+
patch("pathlib.Path.exists", return_value=True), \
|
|
226
|
+
patch("pathlib.Path.mkdir"), \
|
|
227
|
+
patch("pathlib.Path.touch"):
|
|
228
|
+
buf = io.StringIO()
|
|
229
|
+
with redirect_stdout(buf):
|
|
230
|
+
rc = init_repo.run(["mykey", "--github=org/myrepo"])
|
|
231
|
+
self.assertEqual(rc, 0)
|
|
232
|
+
|
|
233
|
+
def test_no_input_called_when_github_missing(self):
|
|
234
|
+
"""Missing --github → rc 2 without prompting."""
|
|
235
|
+
cfg = _make_cfg(repos={})
|
|
236
|
+
|
|
237
|
+
def _raise(*a, **kw):
|
|
238
|
+
raise AssertionError("input() must not be called — command must return rc 2 immediately")
|
|
239
|
+
|
|
240
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
241
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
242
|
+
with patch("commands.init_repo.load_config", return_value=cfg), \
|
|
243
|
+
patch("pathlib.Path.exists", return_value=True):
|
|
244
|
+
buf = io.StringIO()
|
|
245
|
+
with redirect_stdout(buf):
|
|
246
|
+
rc = init_repo.run(["mykey"])
|
|
247
|
+
self.assertEqual(rc, 2)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Phase D: detect-and-import tests
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
class InitRepoSharedTrackDetectionTest(unittest.TestCase):
|
|
255
|
+
"""Tests for the .work-plan/ detection and fallback reporting added in Phase D."""
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Helper that lets us control .work-plan/ contents precisely
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _drive_with_local(self, args, *, local_is_git=True, work_plan_files=None):
|
|
262
|
+
"""Run init_repo.run(args) with fine-grained control over the local clone.
|
|
263
|
+
|
|
264
|
+
local_is_git: whether the local path looks like a valid git repo (.git present).
|
|
265
|
+
work_plan_files: list of filenames inside .work-plan/ (default: no .work-plan/).
|
|
266
|
+
"""
|
|
267
|
+
cfg = _make_cfg(repos={})
|
|
268
|
+
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
269
|
+
|
|
270
|
+
def _is_dir(self):
|
|
271
|
+
s = str(self)
|
|
272
|
+
# .md files are never directories
|
|
273
|
+
if s.endswith(".md"):
|
|
274
|
+
return False
|
|
275
|
+
# dotfiles like .gitkeep are not directories
|
|
276
|
+
name = self.name
|
|
277
|
+
if name.startswith(".") and name != ".work-plan" and name != ".git":
|
|
278
|
+
return False
|
|
279
|
+
if name == ".git":
|
|
280
|
+
return True # .git is a dir
|
|
281
|
+
if name == ".work-plan":
|
|
282
|
+
return work_plan_files is not None
|
|
283
|
+
return True # everything else is a dir
|
|
284
|
+
|
|
285
|
+
def _exists(self):
|
|
286
|
+
s = str(self)
|
|
287
|
+
# notes_root always exists
|
|
288
|
+
if "fake-notes" in s:
|
|
289
|
+
return True
|
|
290
|
+
# Use Path.name so Windows backslash paths match too.
|
|
291
|
+
_name = Path(self).name
|
|
292
|
+
# .git dir exists when local_is_git=True
|
|
293
|
+
if _name == ".git":
|
|
294
|
+
return local_is_git
|
|
295
|
+
# local clone dir always exists
|
|
296
|
+
if _name == "clone":
|
|
297
|
+
return True
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
# Build fake iterdir for .work-plan/
|
|
301
|
+
def _iterdir(self):
|
|
302
|
+
if work_plan_files is None:
|
|
303
|
+
return iter([])
|
|
304
|
+
root = self
|
|
305
|
+
fake_paths = []
|
|
306
|
+
for name in work_plan_files:
|
|
307
|
+
p = root / name
|
|
308
|
+
fake_paths.append(p)
|
|
309
|
+
return iter(fake_paths)
|
|
310
|
+
|
|
311
|
+
with patch("commands.init_repo.load_config", return_value=cfg), \
|
|
312
|
+
patch("commands.init_repo.subprocess.run", return_value=mock_proc), \
|
|
313
|
+
patch("pathlib.Path.exists", _exists), \
|
|
314
|
+
patch("pathlib.Path.is_dir", _is_dir), \
|
|
315
|
+
patch("pathlib.Path.iterdir", _iterdir), \
|
|
316
|
+
patch("pathlib.Path.mkdir"), \
|
|
317
|
+
patch("pathlib.Path.touch"):
|
|
318
|
+
buf = io.StringIO()
|
|
319
|
+
with redirect_stdout(buf):
|
|
320
|
+
rc = init_repo.run(args)
|
|
321
|
+
return rc, buf.getvalue()
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
# init-repo with existing .work-plan/ containing 3 tracks
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def test_found_3_shared_tracks_reported(self):
|
|
328
|
+
"""init-repo --local pointing to a git repo with 3 .md tracks in .work-plan/
|
|
329
|
+
→ output includes 'Found 3 shared track(s)'."""
|
|
330
|
+
rc, out = self._drive_with_local(
|
|
331
|
+
["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
|
|
332
|
+
local_is_git=True,
|
|
333
|
+
work_plan_files=["alpha.md", "beta.md", "gamma.md"],
|
|
334
|
+
)
|
|
335
|
+
self.assertEqual(rc, 0)
|
|
336
|
+
self.assertIn("Found 3 shared track(s)", out)
|
|
337
|
+
|
|
338
|
+
def test_readme_excluded_from_shared_track_count(self):
|
|
339
|
+
"""README.md is excluded from the track count; dotfiles are excluded too."""
|
|
340
|
+
rc, out = self._drive_with_local(
|
|
341
|
+
["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
|
|
342
|
+
local_is_git=True,
|
|
343
|
+
work_plan_files=["alpha.md", "README.md", ".gitkeep"],
|
|
344
|
+
)
|
|
345
|
+
self.assertEqual(rc, 0)
|
|
346
|
+
# Only alpha.md counts; README.md and .gitkeep are excluded
|
|
347
|
+
self.assertIn("Found 1 shared track(s)", out)
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
# init-repo with no --local → registration-only fallback
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def test_no_local_prints_registration_only_fallback(self):
|
|
354
|
+
"""init-repo without --local → 'No valid local clone' message."""
|
|
355
|
+
rc, out = self._drive_with_local(
|
|
356
|
+
["mykey", "--github=org/myrepo"],
|
|
357
|
+
local_is_git=False,
|
|
358
|
+
work_plan_files=None,
|
|
359
|
+
)
|
|
360
|
+
self.assertEqual(rc, 0)
|
|
361
|
+
self.assertIn("No valid local clone", out)
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# init-repo with --local pointing to non-git dir
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def test_local_non_git_dir_prints_registration_only_fallback(self):
|
|
368
|
+
"""--local pointing to a directory that is not a git repo → 'No valid local clone'."""
|
|
369
|
+
rc, out = self._drive_with_local(
|
|
370
|
+
["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
|
|
371
|
+
local_is_git=False,
|
|
372
|
+
work_plan_files=None,
|
|
373
|
+
)
|
|
374
|
+
self.assertEqual(rc, 0)
|
|
375
|
+
self.assertIn("No valid local clone", out)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
unittest.main()
|