@stylusnexus/work-plan 2026.6.9 → 2026.6.10
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 +91 -13
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +71 -17
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +17 -5
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +39 -7
- package/skills/work-plan/commands/init_repo.py +43 -1
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +100 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +19 -6
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +208 -18
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -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_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +128 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +169 -4
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +395 -1
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +38 -18
|
@@ -207,28 +207,66 @@ class FetchIssuesConcurrentTest(unittest.TestCase):
|
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
class FetchIssuesAfterRefactorTest(unittest.TestCase):
|
|
210
|
-
"""Verify fetch_issues
|
|
210
|
+
"""Verify fetch_issues uses batched GraphQL + per-issue fallback."""
|
|
211
211
|
|
|
212
|
-
@patch("lib.github_state.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
213
|
+
@patch("lib.github_state.fetch_issue")
|
|
214
|
+
def test_gql_returns_all_no_fallback(self, mock_fetch_issue, mock_gql):
|
|
215
|
+
"""When GraphQL returns every number, no fallback calls needed."""
|
|
216
|
+
mock_gql.return_value = {
|
|
217
|
+
10: {"number": 10, "state": "OPEN", "labels": [], "title": "a"},
|
|
218
|
+
20: {"number": 20, "state": "CLOSED", "labels": [], "title": "b"},
|
|
219
|
+
}
|
|
220
|
+
result = fetch_issues("org/repo", [10, 20])
|
|
221
|
+
self.assertEqual(len(result), 2)
|
|
222
|
+
self.assertEqual(result[0]["number"], 10)
|
|
223
|
+
self.assertEqual(result[1]["number"], 20)
|
|
224
|
+
mock_fetch_issue.assert_not_called()
|
|
225
|
+
|
|
226
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
227
|
+
@patch("lib.github_state.fetch_issue")
|
|
228
|
+
def test_gql_partial_falls_back(self, mock_fetch_issue, mock_gql):
|
|
229
|
+
"""Numbers missing from GraphQL → per-issue fallback for each."""
|
|
230
|
+
mock_gql.return_value = {
|
|
231
|
+
10: {"number": 10, "state": "OPEN", "labels": []},
|
|
232
|
+
}
|
|
233
|
+
mock_fetch_issue.side_effect = [
|
|
234
|
+
None, # 20 not found via fallback
|
|
235
|
+
{"number": 30, "state": "OPEN", "labels": []},
|
|
236
|
+
]
|
|
237
|
+
result = fetch_issues("org/repo", [10, 20, 30])
|
|
238
|
+
# 10 from GQL, 20 fallback returns None (no side_effect entry), 30 from fallback
|
|
239
|
+
self.assertEqual(len(result), 2)
|
|
240
|
+
self.assertEqual(result[0]["number"], 10)
|
|
241
|
+
self.assertEqual(result[1]["number"], 30)
|
|
242
|
+
# fetch_issue called for 20 and 30 (only numbers not in GQL)
|
|
243
|
+
self.assertEqual(mock_fetch_issue.call_count, 2)
|
|
244
|
+
|
|
245
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
246
|
+
@patch("lib.github_state.fetch_issue")
|
|
247
|
+
def test_gql_empty_falls_back_completely(self, mock_fetch_issue, mock_gql):
|
|
248
|
+
"""Empty GraphQL result → all numbers go to per-issue fallback."""
|
|
249
|
+
mock_gql.return_value = {}
|
|
250
|
+
mock_fetch_issue.side_effect = [
|
|
251
|
+
{"number": 10, "state": "OPEN", "labels": []},
|
|
252
|
+
{"number": 20, "state": "CLOSED", "labels": []},
|
|
217
253
|
]
|
|
218
|
-
mock_run.side_effect = responses
|
|
219
254
|
result = fetch_issues("org/repo", [10, 20])
|
|
220
255
|
self.assertEqual(len(result), 2)
|
|
221
256
|
self.assertEqual(result[0]["number"], 10)
|
|
222
257
|
self.assertEqual(result[1]["number"], 20)
|
|
258
|
+
self.assertEqual(mock_fetch_issue.call_count, 2)
|
|
223
259
|
|
|
224
|
-
@patch("lib.github_state.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
260
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
261
|
+
@patch("lib.github_state.fetch_issue")
|
|
262
|
+
def test_skips_failed_fallbacks(self, mock_fetch_issue, mock_gql):
|
|
263
|
+
"""Per-issue fallback returning None → skipped silently."""
|
|
264
|
+
mock_gql.return_value = {}
|
|
265
|
+
mock_fetch_issue.side_effect = [
|
|
266
|
+
{"number": 10, "state": "OPEN", "labels": []},
|
|
267
|
+
None, # number 20 failed
|
|
268
|
+
{"number": 30, "state": "OPEN", "labels": []},
|
|
230
269
|
]
|
|
231
|
-
mock_run.side_effect = responses
|
|
232
270
|
result = fetch_issues("org/repo", [10, 20, 30])
|
|
233
271
|
self.assertEqual(len(result), 2)
|
|
234
272
|
self.assertEqual(result[0]["number"], 10)
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Tests for group --apply tier-aware routing — Phase C."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch, MagicMock, call
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from commands import group
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def _make_cfg(*, notes_root, repo_entry=None):
|
|
22
|
+
if repo_entry is None:
|
|
23
|
+
repo_entry = {
|
|
24
|
+
"github": "org/myrepo",
|
|
25
|
+
"local": "/home/user/projects/myrepo",
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
"notes_root": str(notes_root),
|
|
29
|
+
"repos": {"myrepo": repo_entry},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_batch(*, repo="org/myrepo", folder="myrepo", milestone="v1.0",
|
|
34
|
+
private=False, issues=None):
|
|
35
|
+
if issues is None:
|
|
36
|
+
issues = [
|
|
37
|
+
{"number": 1, "title": "Issue one", "milestone": None,
|
|
38
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
39
|
+
{"number": 2, "title": "Issue two", "milestone": None,
|
|
40
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
41
|
+
]
|
|
42
|
+
return {
|
|
43
|
+
"repo": repo, "folder": folder, "milestone": milestone,
|
|
44
|
+
"private": private, "issues": issues,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _make_answers(slug="auth-flow", name="Auth Flow", summary="Auth stuff",
|
|
49
|
+
issues=None):
|
|
50
|
+
if issues is None:
|
|
51
|
+
issues = [1, 2]
|
|
52
|
+
return [{"slug": slug, "name": name, "summary": summary, "issues": issues}]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _drive_apply(args, *, cfg, batch, answers, vis="PRIVATE"):
|
|
56
|
+
"""Run group._apply with mocked filesystem and gh calls.
|
|
57
|
+
|
|
58
|
+
Uses real temp files for batch/answers (so Path.exists() on them works),
|
|
59
|
+
and patches Path.exists only for track paths (the per-cluster slug files).
|
|
60
|
+
"""
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
62
|
+
# Write batch and answers to REAL temp files
|
|
63
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
64
|
+
answers_file = Path(tmpdir) / "groups.answers.json"
|
|
65
|
+
batch_file.write_text(json.dumps(batch), encoding="utf-8")
|
|
66
|
+
answers_file.write_text(json.dumps(answers), encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
# Track the path that _apply will try to write slug.md files to
|
|
69
|
+
# For shared route: <local>/.work-plan/<slug>.md
|
|
70
|
+
# For private route: notes_root/folder/<slug>.md
|
|
71
|
+
# We need Path.exists() to return False for those track files but
|
|
72
|
+
# True for the batch/answers files that already exist on disk.
|
|
73
|
+
# Solution: only patch Path.exists for paths that don't actually exist.
|
|
74
|
+
|
|
75
|
+
with patch("commands.group._batch_path", return_value=batch_file), \
|
|
76
|
+
patch("commands.group._answers_path", return_value=answers_file), \
|
|
77
|
+
patch("commands.group.load_config", return_value=cfg), \
|
|
78
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
79
|
+
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
80
|
+
patch("commands.group.write_file") as mw, \
|
|
81
|
+
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
82
|
+
patch("commands.group.seed_readme") as mseed, \
|
|
83
|
+
patch("pathlib.Path.mkdir"):
|
|
84
|
+
# Patch Path.exists to return True for the batch/answers files,
|
|
85
|
+
# False for track files (slug.md paths that don't exist yet).
|
|
86
|
+
_real_exists = Path.exists
|
|
87
|
+
|
|
88
|
+
def _selective_exists(self):
|
|
89
|
+
# Real files on disk → use real check
|
|
90
|
+
if str(self) in (str(batch_file), str(answers_file)):
|
|
91
|
+
return True
|
|
92
|
+
# Track directory for shared route: let it appear to exist
|
|
93
|
+
# so _apply doesn't error out trying to mkdir and fails
|
|
94
|
+
# Use Path.name (not endswith) so Windows backslash paths match too.
|
|
95
|
+
_name = Path(self).name
|
|
96
|
+
if _name == ".work-plan" or _name == "myrepo":
|
|
97
|
+
return True
|
|
98
|
+
# Track .md files: pretend they don't exist (trigger create path)
|
|
99
|
+
if str(self).endswith(".md"):
|
|
100
|
+
return False
|
|
101
|
+
# Everything else: real check
|
|
102
|
+
return _real_exists(self)
|
|
103
|
+
|
|
104
|
+
with patch("pathlib.Path.exists", _selective_exists):
|
|
105
|
+
buf = io.StringIO()
|
|
106
|
+
with redirect_stdout(buf):
|
|
107
|
+
rc = group._apply(cfg, args)
|
|
108
|
+
return rc, mw, mseed, buf.getvalue()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Tests
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class GroupApplyTierRoutingTest(unittest.TestCase):
|
|
116
|
+
|
|
117
|
+
def test_apply_with_valid_clone_routes_to_work_plan_dir(self):
|
|
118
|
+
"""group --apply with a valid clone routes track to .work-plan/<slug>.md."""
|
|
119
|
+
notes_root = "/tmp/fake-notes"
|
|
120
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
121
|
+
batch = _make_batch()
|
|
122
|
+
answers = _make_answers()
|
|
123
|
+
|
|
124
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
125
|
+
answers=answers, vis="PRIVATE")
|
|
126
|
+
self.assertEqual(rc, 0)
|
|
127
|
+
mw.assert_called_once()
|
|
128
|
+
written_path = mw.call_args[0][0]
|
|
129
|
+
# Path should be under .work-plan/, not notes_root
|
|
130
|
+
self.assertIn(".work-plan", str(written_path))
|
|
131
|
+
self.assertNotIn("fake-notes", str(written_path))
|
|
132
|
+
|
|
133
|
+
def test_apply_private_flag_routes_to_notes_root(self):
|
|
134
|
+
"""group --apply --private routes to notes_root/folder/<slug>.md."""
|
|
135
|
+
notes_root = "/tmp/fake-notes"
|
|
136
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
137
|
+
batch = _make_batch(private=False) # Not private in batch
|
|
138
|
+
answers = _make_answers()
|
|
139
|
+
|
|
140
|
+
# But --private in args overrides
|
|
141
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
142
|
+
cfg=cfg, batch=batch,
|
|
143
|
+
answers=answers, vis="PRIVATE")
|
|
144
|
+
self.assertEqual(rc, 0)
|
|
145
|
+
mw.assert_called_once()
|
|
146
|
+
written_path = mw.call_args[0][0]
|
|
147
|
+
# Path should NOT be under .work-plan/
|
|
148
|
+
self.assertNotIn(".work-plan", str(written_path))
|
|
149
|
+
|
|
150
|
+
def test_apply_private_in_batch_routes_to_notes_root(self):
|
|
151
|
+
"""group --apply with private=True stored in batch routes to notes_root."""
|
|
152
|
+
notes_root = "/tmp/fake-notes"
|
|
153
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
154
|
+
batch = _make_batch(private=True) # Private stored in batch
|
|
155
|
+
answers = _make_answers()
|
|
156
|
+
|
|
157
|
+
# No --private in args, but batch says private
|
|
158
|
+
rc, mw, mseed, out = _drive_apply(["--apply"],
|
|
159
|
+
cfg=cfg, batch=batch,
|
|
160
|
+
answers=answers, vis="PRIVATE")
|
|
161
|
+
self.assertEqual(rc, 0)
|
|
162
|
+
mw.assert_called_once()
|
|
163
|
+
written_path = mw.call_args[0][0]
|
|
164
|
+
self.assertNotIn(".work-plan", str(written_path))
|
|
165
|
+
|
|
166
|
+
def test_apply_shared_route_seeds_readme_when_dir_is_new(self):
|
|
167
|
+
"""group --apply seeds README only when .work-plan/ is newly created."""
|
|
168
|
+
notes_root = "/tmp/fake-notes"
|
|
169
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
170
|
+
batch = _make_batch()
|
|
171
|
+
answers = _make_answers()
|
|
172
|
+
|
|
173
|
+
# Make the .work-plan dir appear to NOT exist so the mkdir+seed path runs
|
|
174
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
175
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
176
|
+
answers_file = Path(tmpdir) / "groups.answers.json"
|
|
177
|
+
batch_file.write_text(json.dumps(batch), encoding="utf-8")
|
|
178
|
+
answers_file.write_text(json.dumps(answers), encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
_real_exists = Path.exists
|
|
181
|
+
|
|
182
|
+
def _exists_dir_missing(self):
|
|
183
|
+
if str(self) in (str(batch_file), str(answers_file)):
|
|
184
|
+
return True
|
|
185
|
+
if str(self).endswith(".work-plan"):
|
|
186
|
+
return False # dir not yet created → triggers mkdir+seed
|
|
187
|
+
if str(self).endswith(".md"):
|
|
188
|
+
return False
|
|
189
|
+
return _real_exists(self)
|
|
190
|
+
|
|
191
|
+
with patch("commands.group._batch_path", return_value=batch_file), \
|
|
192
|
+
patch("commands.group._answers_path", return_value=answers_file), \
|
|
193
|
+
patch("commands.group.load_config", return_value=cfg), \
|
|
194
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
195
|
+
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
196
|
+
patch("commands.group.write_file"), \
|
|
197
|
+
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
198
|
+
patch("commands.group.seed_readme") as mseed, \
|
|
199
|
+
patch("pathlib.Path.mkdir"), \
|
|
200
|
+
patch("pathlib.Path.exists", _exists_dir_missing):
|
|
201
|
+
group._apply(cfg, [])
|
|
202
|
+
|
|
203
|
+
mseed.assert_called_once()
|
|
204
|
+
seeded_path = mseed.call_args[0][0]
|
|
205
|
+
self.assertIn(".work-plan", str(seeded_path))
|
|
206
|
+
|
|
207
|
+
def test_apply_shared_route_no_readme_resurrection(self):
|
|
208
|
+
"""group --apply does NOT call seed_readme when .work-plan/ already exists."""
|
|
209
|
+
notes_root = "/tmp/fake-notes"
|
|
210
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
211
|
+
batch = _make_batch()
|
|
212
|
+
answers = _make_answers()
|
|
213
|
+
|
|
214
|
+
# Default _drive_apply mocks .work-plan as existing → seed_readme not called
|
|
215
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
216
|
+
answers=answers, vis="PRIVATE")
|
|
217
|
+
self.assertEqual(rc, 0)
|
|
218
|
+
mseed.assert_not_called()
|
|
219
|
+
|
|
220
|
+
def test_apply_private_route_does_not_seed_readme(self):
|
|
221
|
+
"""group --apply --private does NOT call seed_readme."""
|
|
222
|
+
notes_root = "/tmp/fake-notes"
|
|
223
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
224
|
+
batch = _make_batch()
|
|
225
|
+
answers = _make_answers()
|
|
226
|
+
|
|
227
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
228
|
+
cfg=cfg, batch=batch,
|
|
229
|
+
answers=answers, vis="PRIVATE")
|
|
230
|
+
self.assertEqual(rc, 0)
|
|
231
|
+
mseed.assert_not_called()
|
|
232
|
+
|
|
233
|
+
def test_apply_shared_route_public_repo_prints_headsup(self):
|
|
234
|
+
"""group --apply on a public repo → heads-up printed, non-blocking."""
|
|
235
|
+
notes_root = "/tmp/fake-notes"
|
|
236
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
237
|
+
batch = _make_batch()
|
|
238
|
+
answers = _make_answers()
|
|
239
|
+
|
|
240
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
241
|
+
answers=answers, vis="PUBLIC")
|
|
242
|
+
# Non-blocking: rc 0 and write_file still called
|
|
243
|
+
self.assertEqual(rc, 0)
|
|
244
|
+
mw.assert_called_once()
|
|
245
|
+
self.assertIn("HEADS-UP", out)
|
|
246
|
+
self.assertIn("PUBLIC", out)
|
|
247
|
+
|
|
248
|
+
def test_apply_shared_route_unknown_vis_prints_headsup(self):
|
|
249
|
+
"""group --apply with unknown visibility → heads-up printed."""
|
|
250
|
+
notes_root = "/tmp/fake-notes"
|
|
251
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
252
|
+
batch = _make_batch()
|
|
253
|
+
answers = _make_answers()
|
|
254
|
+
|
|
255
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
256
|
+
answers=answers, vis=None)
|
|
257
|
+
self.assertEqual(rc, 0)
|
|
258
|
+
mw.assert_called_once()
|
|
259
|
+
self.assertIn("HEADS-UP", out)
|
|
260
|
+
|
|
261
|
+
def test_apply_shared_route_new_track_prints_shared_hint(self):
|
|
262
|
+
"""group --apply shared route → new track file gets commit+push hint."""
|
|
263
|
+
notes_root = "/tmp/fake-notes"
|
|
264
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
265
|
+
batch = _make_batch()
|
|
266
|
+
answers = _make_answers()
|
|
267
|
+
|
|
268
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
269
|
+
answers=answers, vis="PRIVATE")
|
|
270
|
+
self.assertEqual(rc, 0)
|
|
271
|
+
self.assertIn("shared", out)
|
|
272
|
+
self.assertIn("commit + push", out)
|
|
273
|
+
|
|
274
|
+
def test_apply_private_route_no_shared_hint(self):
|
|
275
|
+
"""group --apply --private → no commit+push hint."""
|
|
276
|
+
notes_root = "/tmp/fake-notes"
|
|
277
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
278
|
+
batch = _make_batch()
|
|
279
|
+
answers = _make_answers()
|
|
280
|
+
|
|
281
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
282
|
+
cfg=cfg, batch=batch,
|
|
283
|
+
answers=answers, vis="PRIVATE")
|
|
284
|
+
self.assertEqual(rc, 0)
|
|
285
|
+
# No shared hint on the private route
|
|
286
|
+
self.assertNotIn("commit + push", out)
|
|
287
|
+
|
|
288
|
+
def test_prepare_step_stores_private_flag_in_batch(self):
|
|
289
|
+
"""group (prepare step) with --private stores 'private': True in batch JSON."""
|
|
290
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
291
|
+
notes_root = Path(tmpdir) / "notes"
|
|
292
|
+
notes_root.mkdir()
|
|
293
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
294
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
295
|
+
|
|
296
|
+
issues = [
|
|
297
|
+
{"number": 1, "title": "T1", "milestone": None,
|
|
298
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
302
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
303
|
+
patch("commands.group._answers_path",
|
|
304
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
305
|
+
patch("subprocess.run") as mock_run:
|
|
306
|
+
mock_run.return_value = MagicMock(
|
|
307
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
308
|
+
)
|
|
309
|
+
buf = io.StringIO()
|
|
310
|
+
with redirect_stdout(buf):
|
|
311
|
+
rc = group.run(["--repo=myrepo", "--private"])
|
|
312
|
+
|
|
313
|
+
self.assertEqual(rc, 0)
|
|
314
|
+
stored = json.loads(batch_file.read_text())
|
|
315
|
+
self.assertTrue(stored.get("private"))
|
|
316
|
+
|
|
317
|
+
def test_prepare_step_without_private_flag_stores_false(self):
|
|
318
|
+
"""group (prepare) without --private stores 'private': False in batch."""
|
|
319
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
320
|
+
notes_root = Path(tmpdir) / "notes"
|
|
321
|
+
notes_root.mkdir()
|
|
322
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
323
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
324
|
+
|
|
325
|
+
issues = [
|
|
326
|
+
{"number": 1, "title": "T1", "milestone": None,
|
|
327
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
331
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
332
|
+
patch("commands.group._answers_path",
|
|
333
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
334
|
+
patch("subprocess.run") as mock_run:
|
|
335
|
+
mock_run.return_value = MagicMock(
|
|
336
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
337
|
+
)
|
|
338
|
+
buf = io.StringIO()
|
|
339
|
+
with redirect_stdout(buf):
|
|
340
|
+
rc = group.run(["--repo=myrepo"])
|
|
341
|
+
|
|
342
|
+
self.assertEqual(rc, 0)
|
|
343
|
+
stored = json.loads(batch_file.read_text())
|
|
344
|
+
self.assertFalse(stored.get("private"))
|
|
345
|
+
|
|
346
|
+
def test_limit_truncates_issue_display(self):
|
|
347
|
+
"""--limit truncates displayed issues in the AI prompt with remaining count."""
|
|
348
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
349
|
+
notes_root = Path(tmpdir) / "notes"
|
|
350
|
+
notes_root.mkdir()
|
|
351
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
352
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
353
|
+
|
|
354
|
+
issues = [
|
|
355
|
+
{"number": i, "title": f"Issue {i}", "milestone": None,
|
|
356
|
+
"labels": [], "assignees": [], "state": "OPEN"}
|
|
357
|
+
for i in range(1, 25)
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
361
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
362
|
+
patch("commands.group._answers_path",
|
|
363
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
364
|
+
patch("subprocess.run") as mock_run:
|
|
365
|
+
mock_run.return_value = MagicMock(
|
|
366
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
367
|
+
)
|
|
368
|
+
buf = io.StringIO()
|
|
369
|
+
with redirect_stdout(buf):
|
|
370
|
+
rc = group.run(["--repo=myrepo", "--limit=10"])
|
|
371
|
+
|
|
372
|
+
self.assertEqual(rc, 0)
|
|
373
|
+
out = buf.getvalue()
|
|
374
|
+
self.assertIn("Issue 1", out)
|
|
375
|
+
self.assertIn("Issue 10", out)
|
|
376
|
+
self.assertNotIn("Issue 11", out)
|
|
377
|
+
self.assertIn("and 14 more", out)
|
|
378
|
+
self.assertIn("--limit", out)
|
|
379
|
+
|
|
380
|
+
def test_limit_at_or_below_count_shows_all_no_truncation(self):
|
|
381
|
+
"""When issue count is within --limit, no truncation message appears."""
|
|
382
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
383
|
+
notes_root = Path(tmpdir) / "notes"
|
|
384
|
+
notes_root.mkdir()
|
|
385
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
386
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
387
|
+
|
|
388
|
+
issues = [
|
|
389
|
+
{"number": 1, "title": "Issue 1", "milestone": None,
|
|
390
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
394
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
395
|
+
patch("commands.group._answers_path",
|
|
396
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
397
|
+
patch("subprocess.run") as mock_run:
|
|
398
|
+
mock_run.return_value = MagicMock(
|
|
399
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
400
|
+
)
|
|
401
|
+
buf = io.StringIO()
|
|
402
|
+
with redirect_stdout(buf):
|
|
403
|
+
rc = group.run(["--repo=myrepo"])
|
|
404
|
+
|
|
405
|
+
self.assertEqual(rc, 0)
|
|
406
|
+
out = buf.getvalue()
|
|
407
|
+
self.assertNotIn("more issues", out)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
unittest.main()
|
|
@@ -247,5 +247,133 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
247
247
|
self.assertEqual(rc, 2)
|
|
248
248
|
|
|
249
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
|
+
|
|
250
378
|
if __name__ == "__main__":
|
|
251
379
|
unittest.main()
|