@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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Tests for track discovery."""
|
|
2
|
-
import
|
|
2
|
+
import io
|
|
3
3
|
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
4
6
|
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
5
8
|
|
|
6
9
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
10
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
@@ -10,6 +13,29 @@ from lib.tracks import discover_tracks, discover_archived_tracks
|
|
|
10
13
|
|
|
11
14
|
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
12
15
|
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Helpers
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _make_git_repo(base: Path) -> Path:
|
|
21
|
+
"""Create a minimal fake git repo at base (has a .git dir)."""
|
|
22
|
+
(base / ".git").mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return base
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_track_md(path: Path, track_name: str, repo: str,
|
|
27
|
+
status: str = "active") -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(
|
|
30
|
+
f"---\ntrack: {track_name}\nstatus: {status}\n"
|
|
31
|
+
f"github:\n repo: {repo}\n issues: []\n---\n\n# {track_name}\n",
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Existing private-notes tests (unchanged behaviour)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
13
39
|
|
|
14
40
|
class DiscoverTracksTest(unittest.TestCase):
|
|
15
41
|
def setUp(self):
|
|
@@ -38,6 +64,13 @@ class DiscoverTracksTest(unittest.TestCase):
|
|
|
38
64
|
names = [t.name for t in discover_tracks(self.cfg)]
|
|
39
65
|
self.assertNotIn("old", names)
|
|
40
66
|
|
|
67
|
+
def test_private_tracks_tagged_private(self):
|
|
68
|
+
"""All tracks from notes_root carry tier='private'."""
|
|
69
|
+
tracks = discover_tracks(self.cfg)
|
|
70
|
+
for t in tracks:
|
|
71
|
+
self.assertEqual(t.tier, "private",
|
|
72
|
+
f"Expected tier='private' for {t.path}")
|
|
73
|
+
|
|
41
74
|
|
|
42
75
|
class DiscoverArchivedTracksTest(unittest.TestCase):
|
|
43
76
|
def setUp(self):
|
|
@@ -52,5 +85,366 @@ class DiscoverArchivedTracksTest(unittest.TestCase):
|
|
|
52
85
|
self.assertIn("old", slugs)
|
|
53
86
|
|
|
54
87
|
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Shared-notes tier tests
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class SharedTrackDiscoveryTest(unittest.TestCase):
|
|
93
|
+
"""Shared tracks live in <local>/.work-plan/ and are tagged tier='shared'."""
|
|
94
|
+
|
|
95
|
+
def _make_cfg(self, local_clone: Path, notes_root: Path) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"notes_root": str(notes_root),
|
|
98
|
+
"repos": {
|
|
99
|
+
"myrepo": {
|
|
100
|
+
"github": "org/myrepo",
|
|
101
|
+
"local": str(local_clone),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def test_shared_track_discovered_and_tagged(self):
|
|
107
|
+
"""A track in <local>/.work-plan/ is discovered with tier='shared'."""
|
|
108
|
+
with tempfile.TemporaryDirectory() as d:
|
|
109
|
+
base = Path(d)
|
|
110
|
+
clone = _make_git_repo(base / "clone")
|
|
111
|
+
wp = clone / ".work-plan" / "myrepo"
|
|
112
|
+
_write_track_md(wp / "feat-x.md", "feat-x", "org/myrepo")
|
|
113
|
+
notes = base / "notes"
|
|
114
|
+
notes.mkdir()
|
|
115
|
+
cfg = self._make_cfg(clone, notes)
|
|
116
|
+
|
|
117
|
+
tracks = discover_tracks(cfg)
|
|
118
|
+
names = [t.name for t in tracks]
|
|
119
|
+
self.assertIn("feat-x", names)
|
|
120
|
+
shared = next(t for t in tracks if t.name == "feat-x")
|
|
121
|
+
self.assertEqual(shared.tier, "shared")
|
|
122
|
+
|
|
123
|
+
def test_shared_track_repo_from_folder_config(self):
|
|
124
|
+
"""repo and local_path on a shared track come from folder config, not frontmatter."""
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
base = Path(d)
|
|
127
|
+
clone = _make_git_repo(base / "clone")
|
|
128
|
+
wp = clone / ".work-plan"
|
|
129
|
+
_write_track_md(wp / "feat-y.md", "feat-y", "org/myrepo")
|
|
130
|
+
notes = base / "notes"
|
|
131
|
+
notes.mkdir()
|
|
132
|
+
cfg = self._make_cfg(clone, notes)
|
|
133
|
+
|
|
134
|
+
tracks = discover_tracks(cfg)
|
|
135
|
+
t = next(t for t in tracks if t.name == "feat-y")
|
|
136
|
+
self.assertEqual(t.repo, "org/myrepo")
|
|
137
|
+
self.assertEqual(
|
|
138
|
+
str(Path(t.local_path).resolve()),
|
|
139
|
+
str(clone.resolve()),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_archive_dir_skipped_by_discover_tracks(self):
|
|
143
|
+
"""Files inside .work-plan/archive/ are excluded from active discovery."""
|
|
144
|
+
with tempfile.TemporaryDirectory() as d:
|
|
145
|
+
base = Path(d)
|
|
146
|
+
clone = _make_git_repo(base / "clone")
|
|
147
|
+
_write_track_md(
|
|
148
|
+
clone / ".work-plan" / "archive" / "old.md", "old", "org/myrepo"
|
|
149
|
+
)
|
|
150
|
+
notes = base / "notes"
|
|
151
|
+
notes.mkdir()
|
|
152
|
+
cfg = self._make_cfg(clone, notes)
|
|
153
|
+
|
|
154
|
+
tracks = discover_tracks(cfg)
|
|
155
|
+
names = [t.name for t in tracks]
|
|
156
|
+
self.assertNotIn("old", names)
|
|
157
|
+
|
|
158
|
+
def test_dotfiles_skipped_in_work_plan(self):
|
|
159
|
+
"""Dotfiles inside .work-plan/ are not discovered."""
|
|
160
|
+
with tempfile.TemporaryDirectory() as d:
|
|
161
|
+
base = Path(d)
|
|
162
|
+
clone = _make_git_repo(base / "clone")
|
|
163
|
+
wp = clone / ".work-plan"
|
|
164
|
+
wp.mkdir(parents=True)
|
|
165
|
+
(wp / ".hidden.md").write_text("---\ntrack: hidden\n---\n", encoding="utf-8")
|
|
166
|
+
notes = base / "notes"
|
|
167
|
+
notes.mkdir()
|
|
168
|
+
cfg = self._make_cfg(clone, notes)
|
|
169
|
+
|
|
170
|
+
tracks = discover_tracks(cfg)
|
|
171
|
+
names = [t.name for t in tracks]
|
|
172
|
+
self.assertNotIn(".hidden", names)
|
|
173
|
+
|
|
174
|
+
def test_readme_skipped_in_work_plan(self):
|
|
175
|
+
"""README.md inside .work-plan/ is not discovered as a track."""
|
|
176
|
+
with tempfile.TemporaryDirectory() as d:
|
|
177
|
+
base = Path(d)
|
|
178
|
+
clone = _make_git_repo(base / "clone")
|
|
179
|
+
wp = clone / ".work-plan"
|
|
180
|
+
wp.mkdir(parents=True)
|
|
181
|
+
(wp / "README.md").write_text("# Notes\n", encoding="utf-8")
|
|
182
|
+
notes = base / "notes"
|
|
183
|
+
notes.mkdir()
|
|
184
|
+
cfg = self._make_cfg(clone, notes)
|
|
185
|
+
|
|
186
|
+
tracks = discover_tracks(cfg)
|
|
187
|
+
names = [t.name for t in tracks]
|
|
188
|
+
self.assertNotIn("README", names)
|
|
189
|
+
|
|
190
|
+
def test_invalid_local_path_contributes_no_tracks(self):
|
|
191
|
+
"""Repos with no/invalid local path produce zero shared tracks."""
|
|
192
|
+
with tempfile.TemporaryDirectory() as d:
|
|
193
|
+
base = Path(d)
|
|
194
|
+
notes = base / "notes"
|
|
195
|
+
notes.mkdir()
|
|
196
|
+
cfg = {
|
|
197
|
+
"notes_root": str(notes),
|
|
198
|
+
"repos": {
|
|
199
|
+
"noclone": {
|
|
200
|
+
"github": "org/noclone",
|
|
201
|
+
"local": str(base / "nonexistent"),
|
|
202
|
+
},
|
|
203
|
+
"nolocal": {
|
|
204
|
+
"github": "org/nolocal",
|
|
205
|
+
"local": None,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
# Should return empty list without raising
|
|
210
|
+
tracks = discover_tracks(cfg)
|
|
211
|
+
self.assertEqual(tracks, [])
|
|
212
|
+
|
|
213
|
+
def test_non_git_repo_local_contributes_no_tracks(self):
|
|
214
|
+
"""A local path that exists but has no .git/ is skipped silently."""
|
|
215
|
+
with tempfile.TemporaryDirectory() as d:
|
|
216
|
+
base = Path(d)
|
|
217
|
+
not_a_repo = base / "notarepo"
|
|
218
|
+
not_a_repo.mkdir()
|
|
219
|
+
# Create .work-plan with a track but NO .git dir
|
|
220
|
+
_write_track_md(
|
|
221
|
+
not_a_repo / ".work-plan" / "feat.md", "feat", "org/repo"
|
|
222
|
+
)
|
|
223
|
+
notes = base / "notes"
|
|
224
|
+
notes.mkdir()
|
|
225
|
+
cfg = {
|
|
226
|
+
"notes_root": str(notes),
|
|
227
|
+
"repos": {
|
|
228
|
+
"repo": {
|
|
229
|
+
"github": "org/repo",
|
|
230
|
+
"local": str(not_a_repo),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
tracks = discover_tracks(cfg)
|
|
235
|
+
self.assertEqual(tracks, [])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class SharedTrackCollisionTest(unittest.TestCase):
|
|
239
|
+
"""When (repo, name) appears in both shared and private, shared wins + warns."""
|
|
240
|
+
|
|
241
|
+
def test_shared_wins_on_collision(self):
|
|
242
|
+
with tempfile.TemporaryDirectory() as d:
|
|
243
|
+
base = Path(d)
|
|
244
|
+
clone = _make_git_repo(base / "clone")
|
|
245
|
+
# Shared track
|
|
246
|
+
_write_track_md(
|
|
247
|
+
clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
|
|
248
|
+
)
|
|
249
|
+
# Private track with SAME name in notes_root
|
|
250
|
+
notes = base / "notes"
|
|
251
|
+
_write_track_md(
|
|
252
|
+
notes / "repo" / "feat-x.md", "feat-x", "org/repo"
|
|
253
|
+
)
|
|
254
|
+
cfg = {
|
|
255
|
+
"notes_root": str(notes),
|
|
256
|
+
"repos": {
|
|
257
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tracks = discover_tracks(cfg)
|
|
262
|
+
feat_x_tracks = [t for t in tracks if t.name == "feat-x"]
|
|
263
|
+
# Exactly one track survives the collision
|
|
264
|
+
self.assertEqual(len(feat_x_tracks), 1)
|
|
265
|
+
self.assertEqual(feat_x_tracks[0].tier, "shared")
|
|
266
|
+
|
|
267
|
+
def test_collision_emits_one_warning(self):
|
|
268
|
+
with tempfile.TemporaryDirectory() as d:
|
|
269
|
+
base = Path(d)
|
|
270
|
+
clone = _make_git_repo(base / "clone")
|
|
271
|
+
_write_track_md(
|
|
272
|
+
clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
|
|
273
|
+
)
|
|
274
|
+
notes = base / "notes"
|
|
275
|
+
_write_track_md(
|
|
276
|
+
notes / "repo" / "feat-x.md", "feat-x", "org/repo"
|
|
277
|
+
)
|
|
278
|
+
cfg = {
|
|
279
|
+
"notes_root": str(notes),
|
|
280
|
+
"repos": {
|
|
281
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
with patch("sys.stderr", new_callable=io.StringIO) as mock_err:
|
|
286
|
+
discover_tracks(cfg)
|
|
287
|
+
output = mock_err.getvalue()
|
|
288
|
+
# One warning about the collision
|
|
289
|
+
self.assertEqual(output.count("WARN:"), 1)
|
|
290
|
+
self.assertIn("feat-x", output)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class SharedTrackSingleOwnerTest(unittest.TestCase):
|
|
294
|
+
"""Frontmatter github.repo that disagrees with folder config → warn, use folder."""
|
|
295
|
+
|
|
296
|
+
def test_frontmatter_repo_disagreement_warns_and_uses_folder(self):
|
|
297
|
+
with tempfile.TemporaryDirectory() as d:
|
|
298
|
+
base = Path(d)
|
|
299
|
+
clone = _make_git_repo(base / "clone")
|
|
300
|
+
# Frontmatter says a DIFFERENT repo than the folder config
|
|
301
|
+
_write_track_md(
|
|
302
|
+
clone / ".work-plan" / "feat.md", "feat", "wrong/repo"
|
|
303
|
+
)
|
|
304
|
+
notes = base / "notes"
|
|
305
|
+
notes.mkdir()
|
|
306
|
+
cfg = {
|
|
307
|
+
"notes_root": str(notes),
|
|
308
|
+
"repos": {
|
|
309
|
+
"myrepo": {"github": "correct/repo", "local": str(clone)},
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
stderr_buf = io.StringIO()
|
|
314
|
+
with patch("sys.stderr", stderr_buf):
|
|
315
|
+
tracks = discover_tracks(cfg)
|
|
316
|
+
|
|
317
|
+
t = next(t for t in tracks if t.name == "feat")
|
|
318
|
+
# Folder config wins
|
|
319
|
+
self.assertEqual(t.repo, "correct/repo")
|
|
320
|
+
# Warning was emitted
|
|
321
|
+
self.assertIn("WARN:", stderr_buf.getvalue())
|
|
322
|
+
self.assertIn("correct/repo", stderr_buf.getvalue())
|
|
323
|
+
|
|
324
|
+
def test_frontmatter_repo_match_no_warning(self):
|
|
325
|
+
with tempfile.TemporaryDirectory() as d:
|
|
326
|
+
base = Path(d)
|
|
327
|
+
clone = _make_git_repo(base / "clone")
|
|
328
|
+
_write_track_md(
|
|
329
|
+
clone / ".work-plan" / "feat.md", "feat", "correct/repo"
|
|
330
|
+
)
|
|
331
|
+
notes = base / "notes"
|
|
332
|
+
notes.mkdir()
|
|
333
|
+
cfg = {
|
|
334
|
+
"notes_root": str(notes),
|
|
335
|
+
"repos": {
|
|
336
|
+
"myrepo": {"github": "correct/repo", "local": str(clone)},
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
stderr_buf = io.StringIO()
|
|
341
|
+
with patch("sys.stderr", stderr_buf):
|
|
342
|
+
discover_tracks(cfg)
|
|
343
|
+
|
|
344
|
+
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class DiscoverArchivedSharedTest(unittest.TestCase):
|
|
348
|
+
"""discover_archived_tracks also scans shared repos' .work-plan/archive/."""
|
|
349
|
+
|
|
350
|
+
def test_shared_archived_track_found(self):
|
|
351
|
+
with tempfile.TemporaryDirectory() as d:
|
|
352
|
+
base = Path(d)
|
|
353
|
+
clone = _make_git_repo(base / "clone")
|
|
354
|
+
_write_track_md(
|
|
355
|
+
clone / ".work-plan" / "archive" / "shipped.md",
|
|
356
|
+
"shipped", "org/repo", status="shipped",
|
|
357
|
+
)
|
|
358
|
+
notes = base / "notes"
|
|
359
|
+
notes.mkdir()
|
|
360
|
+
cfg = {
|
|
361
|
+
"notes_root": str(notes),
|
|
362
|
+
"repos": {
|
|
363
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
archived = discover_archived_tracks(cfg)
|
|
368
|
+
names = [t.name for t in archived]
|
|
369
|
+
self.assertIn("shipped", names)
|
|
370
|
+
t = next(t for t in archived if t.name == "shipped")
|
|
371
|
+
self.assertEqual(t.tier, "shared")
|
|
372
|
+
|
|
373
|
+
def test_private_archived_track_still_found(self):
|
|
374
|
+
"""Existing notes_root archives continue to be discovered."""
|
|
375
|
+
cfg = {
|
|
376
|
+
"notes_root": str(FIXTURES),
|
|
377
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
378
|
+
}
|
|
379
|
+
archived = discover_archived_tracks(cfg)
|
|
380
|
+
slugs = [a.meta.get("track") for a in archived]
|
|
381
|
+
self.assertIn("old", slugs)
|
|
382
|
+
|
|
383
|
+
def test_archived_collision_shared_wins(self):
|
|
384
|
+
"""When (repo, name) collides in archived tracks, shared wins + warns."""
|
|
385
|
+
with tempfile.TemporaryDirectory() as d:
|
|
386
|
+
base = Path(d)
|
|
387
|
+
clone = _make_git_repo(base / "clone")
|
|
388
|
+
# Shared archived track
|
|
389
|
+
_write_track_md(
|
|
390
|
+
clone / ".work-plan" / "archive" / "shipped.md",
|
|
391
|
+
"shipped", "org/repo", status="shipped",
|
|
392
|
+
)
|
|
393
|
+
# Private archived track with SAME name in notes_root
|
|
394
|
+
notes = base / "notes"
|
|
395
|
+
_write_track_md(
|
|
396
|
+
notes / "repo" / "archive" / "shipped.md",
|
|
397
|
+
"shipped", "org/repo", status="shipped",
|
|
398
|
+
)
|
|
399
|
+
cfg = {
|
|
400
|
+
"notes_root": str(notes),
|
|
401
|
+
"repos": {
|
|
402
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
stderr_buf = io.StringIO()
|
|
407
|
+
with patch("sys.stderr", stderr_buf):
|
|
408
|
+
archived = discover_archived_tracks(cfg)
|
|
409
|
+
|
|
410
|
+
# Exactly one entry for "shipped"
|
|
411
|
+
shipped = [t for t in archived if t.name == "shipped"]
|
|
412
|
+
self.assertEqual(len(shipped), 1)
|
|
413
|
+
self.assertEqual(shipped[0].tier, "shared")
|
|
414
|
+
# Warning was emitted
|
|
415
|
+
self.assertIn("WARN:", stderr_buf.getvalue())
|
|
416
|
+
self.assertIn("shipped", stderr_buf.getvalue())
|
|
417
|
+
|
|
418
|
+
def test_archived_no_collision_no_warning(self):
|
|
419
|
+
"""Different names in shared/private archives → no warning, both returned."""
|
|
420
|
+
with tempfile.TemporaryDirectory() as d:
|
|
421
|
+
base = Path(d)
|
|
422
|
+
clone = _make_git_repo(base / "clone")
|
|
423
|
+
_write_track_md(
|
|
424
|
+
clone / ".work-plan" / "archive" / "shared-archived.md",
|
|
425
|
+
"shared-archived", "org/repo", status="shipped",
|
|
426
|
+
)
|
|
427
|
+
notes = base / "notes"
|
|
428
|
+
_write_track_md(
|
|
429
|
+
notes / "repo" / "archive" / "private-archived.md",
|
|
430
|
+
"private-archived", "org/repo", status="shipped",
|
|
431
|
+
)
|
|
432
|
+
cfg = {
|
|
433
|
+
"notes_root": str(notes),
|
|
434
|
+
"repos": {
|
|
435
|
+
"repo": {"github": "org/repo", "local": str(clone)},
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
stderr_buf = io.StringIO()
|
|
440
|
+
with patch("sys.stderr", stderr_buf):
|
|
441
|
+
archived = discover_archived_tracks(cfg)
|
|
442
|
+
|
|
443
|
+
names = [t.name for t in archived]
|
|
444
|
+
self.assertIn("shared-archived", names)
|
|
445
|
+
self.assertIn("private-archived", names)
|
|
446
|
+
self.assertNotIn("WARN:", stderr_buf.getvalue())
|
|
447
|
+
|
|
448
|
+
|
|
55
449
|
if __name__ == "__main__":
|
|
56
450
|
unittest.main()
|
|
@@ -378,5 +378,140 @@ class WhereWasINoSessionLogCase(unittest.TestCase):
|
|
|
378
378
|
self.assertIn("Last session: (none yet)", out)
|
|
379
379
|
|
|
380
380
|
|
|
381
|
+
class OrientRepoFlagTest(unittest.TestCase):
|
|
382
|
+
"""orient command --repo=<key> and track@repo disambiguation."""
|
|
383
|
+
|
|
384
|
+
def setUp(self):
|
|
385
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
386
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
387
|
+
self.notes_root.mkdir(parents=True)
|
|
388
|
+
# Create two tracks with the same slug in different repos
|
|
389
|
+
for folder in ("repo-a", "repo-b"):
|
|
390
|
+
repo_dir = self.notes_root / folder
|
|
391
|
+
repo_dir.mkdir(parents=True)
|
|
392
|
+
_make_track_file(repo_dir, slug="feat-x")
|
|
393
|
+
|
|
394
|
+
self.cfg = {
|
|
395
|
+
"notes_root": str(self.notes_root),
|
|
396
|
+
"repos": {
|
|
397
|
+
"repo-a": {"github": "org/repo-a"},
|
|
398
|
+
"repo-b": {"github": "org/repo-b"},
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
def tearDown(self):
|
|
403
|
+
self.tmp.cleanup()
|
|
404
|
+
|
|
405
|
+
def _drive(self, args, *, find_result=None):
|
|
406
|
+
"""Drive orient.run() with load_config mocked. If find_result is None
|
|
407
|
+
(the normal case), discover_tracks runs for real against tmp files.
|
|
408
|
+
When find_result is an Exception, we mock find_track_by_name."""
|
|
409
|
+
patches = [
|
|
410
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
411
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
412
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
413
|
+
return_value={}),
|
|
414
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
415
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
416
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
417
|
+
]
|
|
418
|
+
if find_result is not None:
|
|
419
|
+
patches.append(
|
|
420
|
+
mock.patch("commands.where_was_i.find_track_by_name",
|
|
421
|
+
side_effect=find_result
|
|
422
|
+
if isinstance(find_result, Exception)
|
|
423
|
+
else None,
|
|
424
|
+
return_value=find_result
|
|
425
|
+
if not isinstance(find_result, Exception)
|
|
426
|
+
else None)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
for p in patches:
|
|
430
|
+
p.start()
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
buf = io.StringIO()
|
|
434
|
+
with redirect_stdout(buf):
|
|
435
|
+
rc = where_was_i.run(args)
|
|
436
|
+
return rc, buf.getvalue()
|
|
437
|
+
finally:
|
|
438
|
+
for p in patches:
|
|
439
|
+
p.stop()
|
|
440
|
+
|
|
441
|
+
def test_repo_flag_passed_to_find_track(self):
|
|
442
|
+
"""--repo=<key> is passed as repo= kwarg to find_track_by_name."""
|
|
443
|
+
find_mock = mock.MagicMock()
|
|
444
|
+
find_mock.return_value = None # We just care about how it was called
|
|
445
|
+
|
|
446
|
+
patches = [
|
|
447
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
448
|
+
mock.patch("commands.where_was_i.find_track_by_name", find_mock),
|
|
449
|
+
mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
|
|
450
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
451
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
452
|
+
return_value={}),
|
|
453
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
454
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
455
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
456
|
+
]
|
|
457
|
+
for p in patches:
|
|
458
|
+
p.start()
|
|
459
|
+
try:
|
|
460
|
+
buf = io.StringIO()
|
|
461
|
+
with redirect_stdout(buf):
|
|
462
|
+
where_was_i.run(["feat-x", "--repo=repo-a"])
|
|
463
|
+
finally:
|
|
464
|
+
for p in patches:
|
|
465
|
+
p.stop()
|
|
466
|
+
|
|
467
|
+
call_kwargs = find_mock.call_args.kwargs
|
|
468
|
+
self.assertEqual(call_kwargs.get("repo"), "repo-a")
|
|
469
|
+
|
|
470
|
+
def test_at_syntax_passed_to_find_track(self):
|
|
471
|
+
"""feat-x@repo-a positional → repo='repo-a' passed to find_track_by_name."""
|
|
472
|
+
find_mock = mock.MagicMock()
|
|
473
|
+
find_mock.return_value = None
|
|
474
|
+
|
|
475
|
+
patches = [
|
|
476
|
+
mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
|
|
477
|
+
mock.patch("commands.where_was_i.find_track_by_name", find_mock),
|
|
478
|
+
mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
|
|
479
|
+
mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
|
|
480
|
+
mock.patch("commands.where_was_i.find_new_issues_for_tracks",
|
|
481
|
+
return_value={}),
|
|
482
|
+
mock.patch("commands.where_was_i.current_branch", return_value=None),
|
|
483
|
+
mock.patch("commands.where_was_i.commits_ahead", return_value=0),
|
|
484
|
+
mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
|
|
485
|
+
]
|
|
486
|
+
for p in patches:
|
|
487
|
+
p.start()
|
|
488
|
+
try:
|
|
489
|
+
buf = io.StringIO()
|
|
490
|
+
with redirect_stdout(buf):
|
|
491
|
+
where_was_i.run(["feat-x@repo-a"])
|
|
492
|
+
finally:
|
|
493
|
+
for p in patches:
|
|
494
|
+
p.stop()
|
|
495
|
+
|
|
496
|
+
call_kwargs = find_mock.call_args.kwargs
|
|
497
|
+
self.assertEqual(call_kwargs.get("repo"), "repo-a")
|
|
498
|
+
|
|
499
|
+
def test_ambiguous_error_returns_rc1(self):
|
|
500
|
+
"""AmbiguousTrackError → prints message, returns 1."""
|
|
501
|
+
from lib.tracks import Track, AmbiguousTrackError
|
|
502
|
+
|
|
503
|
+
t1 = Track(path=Path("/tmp/fake/repo-a/feat-x.md"), name="feat-x",
|
|
504
|
+
has_frontmatter=True, needs_init=False, needs_filing=False,
|
|
505
|
+
repo="org/a", folder="repo-a", meta={"track": "feat-x", "status": "active"})
|
|
506
|
+
t2 = Track(path=Path("/tmp/fake/repo-b/feat-x.md"), name="feat-x",
|
|
507
|
+
has_frontmatter=True, needs_init=False, needs_filing=False,
|
|
508
|
+
repo="org/b", folder="repo-b", meta={"track": "feat-x", "status": "active"})
|
|
509
|
+
err = AmbiguousTrackError("feat-x", [t1, t2])
|
|
510
|
+
|
|
511
|
+
rc, out = self._drive(["feat-x"], find_result=err)
|
|
512
|
+
self.assertEqual(rc, 1)
|
|
513
|
+
self.assertIn("ambiguous", out.lower())
|
|
514
|
+
|
|
515
|
+
|
|
381
516
|
if __name__ == "__main__":
|
|
382
517
|
unittest.main()
|