@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
|
@@ -18,6 +18,7 @@ Covers:
|
|
|
18
18
|
"""
|
|
19
19
|
import io
|
|
20
20
|
import json
|
|
21
|
+
import subprocess
|
|
21
22
|
import sys
|
|
22
23
|
import unittest
|
|
23
24
|
from contextlib import redirect_stdout
|
|
@@ -307,7 +308,7 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
307
308
|
self.assertEqual(rc, 0)
|
|
308
309
|
meta = mw.call_args[0][1]
|
|
309
310
|
for key in ("track", "status", "launch_priority", "milestone_alignment",
|
|
310
|
-
"github", "
|
|
311
|
+
"github", "depends_on", "last_touched", "last_handoff",
|
|
311
312
|
"next_up", "blockers"):
|
|
312
313
|
self.assertIn(key, meta, f"meta missing key: {key}")
|
|
313
314
|
|
|
@@ -319,12 +320,12 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
319
320
|
self.assertEqual(meta["github"]["issues"], [])
|
|
320
321
|
self.assertEqual(meta["github"]["branches"], [])
|
|
321
322
|
|
|
322
|
-
def
|
|
323
|
-
"""New track starts with empty
|
|
323
|
+
def test_meta_depends_on_next_up_blockers_empty(self):
|
|
324
|
+
"""New track starts with empty depends_on, next_up, blockers."""
|
|
324
325
|
rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
|
|
325
326
|
self.assertEqual(rc, 0)
|
|
326
327
|
meta = mw.call_args[0][1]
|
|
327
|
-
self.assertEqual(meta["
|
|
328
|
+
self.assertEqual(meta["depends_on"], [])
|
|
328
329
|
self.assertEqual(meta["next_up"], [])
|
|
329
330
|
self.assertEqual(meta["blockers"], [])
|
|
330
331
|
|
|
@@ -441,5 +442,169 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
441
442
|
mw.assert_called_once()
|
|
442
443
|
|
|
443
444
|
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Phase D: --commit flag tests
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
CLONE_ROOT = "/tmp/fake-clone"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _make_cfg_with_local(*, local=CLONE_ROOT):
|
|
453
|
+
"""Config with a repo entry that has a local clone path."""
|
|
454
|
+
return {
|
|
455
|
+
"notes_root": NOTES_ROOT,
|
|
456
|
+
"repos": {
|
|
457
|
+
"myrepo": {"github": "org/myrepo", "local": local},
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class NewTrackCommitFlagTest(unittest.TestCase):
|
|
463
|
+
"""Tests for --commit flag on new-track (Phase D)."""
|
|
464
|
+
|
|
465
|
+
def _drive_shared(self, args, *, git_returncode=0, path_exists=False):
|
|
466
|
+
"""Drive new-track with a shared-tier setup (local clone is a valid git repo)."""
|
|
467
|
+
cfg = _make_cfg_with_local()
|
|
468
|
+
|
|
469
|
+
def _path_exists(self):
|
|
470
|
+
# NOTES_ROOT itself exists
|
|
471
|
+
if self == Path(NOTES_ROOT):
|
|
472
|
+
return True
|
|
473
|
+
# .git dir inside the clone root exists (valid git repo)
|
|
474
|
+
if str(self) == f"{CLONE_ROOT}/.git":
|
|
475
|
+
return True
|
|
476
|
+
# The clone root itself exists
|
|
477
|
+
if str(self) == CLONE_ROOT:
|
|
478
|
+
return True
|
|
479
|
+
# The target .md path: controlled by path_exists
|
|
480
|
+
if self.suffix == ".md":
|
|
481
|
+
return path_exists
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
def _is_dir(self):
|
|
485
|
+
s = str(self)
|
|
486
|
+
if s.endswith(".md"):
|
|
487
|
+
return False
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
# git subprocess: first call (rev-parse), then add, then commit
|
|
491
|
+
git_results = [
|
|
492
|
+
MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse
|
|
493
|
+
MagicMock(returncode=git_returncode, stdout="", stderr="error msg"), # add
|
|
494
|
+
MagicMock(returncode=git_returncode, stdout="", stderr=""), # commit
|
|
495
|
+
]
|
|
496
|
+
git_call_index = {"n": 0}
|
|
497
|
+
|
|
498
|
+
def _git_run(cmd, **kwargs):
|
|
499
|
+
idx = git_call_index["n"]
|
|
500
|
+
git_call_index["n"] += 1
|
|
501
|
+
if git_returncode != 0 and idx > 0:
|
|
502
|
+
raise subprocess.CalledProcessError(git_returncode, cmd, stderr="error msg")
|
|
503
|
+
return git_results[min(idx, len(git_results) - 1)]
|
|
504
|
+
|
|
505
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
506
|
+
patch("commands.new_track.write_file") as mw, \
|
|
507
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
508
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
509
|
+
patch("pathlib.Path.is_dir", _is_dir), \
|
|
510
|
+
patch("pathlib.Path.mkdir"), \
|
|
511
|
+
patch("commands.new_track.subprocess.run", side_effect=_git_run) as msub:
|
|
512
|
+
buf = io.StringIO()
|
|
513
|
+
with redirect_stdout(buf):
|
|
514
|
+
rc = new_track.run(args)
|
|
515
|
+
return rc, mw, msub, buf.getvalue()
|
|
516
|
+
|
|
517
|
+
def test_commit_shared_track_calls_git_add_then_commit(self):
|
|
518
|
+
"""--commit on a shared track: git -C <clone_root> add <file> called,
|
|
519
|
+
then git -C <clone_root> commit called; path-scoped (not git add .)."""
|
|
520
|
+
rc, mw, msub, out = self._drive_shared(
|
|
521
|
+
["myrepo", "my-feature", "--commit"]
|
|
522
|
+
)
|
|
523
|
+
self.assertEqual(rc, 0)
|
|
524
|
+
mw.assert_called_once()
|
|
525
|
+
# Should have made git calls: rev-parse, add, commit
|
|
526
|
+
calls = msub.call_args_list
|
|
527
|
+
# Find the add and commit calls (skip rev-parse at index 0)
|
|
528
|
+
git_cmds = [c[0][0] for c in calls]
|
|
529
|
+
add_calls = [c for c in git_cmds if "add" in c]
|
|
530
|
+
commit_calls = [c for c in git_cmds if "commit" in c]
|
|
531
|
+
self.assertEqual(len(add_calls), 1, "exactly one git add call expected")
|
|
532
|
+
self.assertEqual(len(commit_calls), 1, "exactly one git commit call expected")
|
|
533
|
+
# Verify add is path-scoped (not "git add .")
|
|
534
|
+
add_argv = add_calls[0]
|
|
535
|
+
self.assertNotIn(".", add_argv, "git add must be path-scoped, not 'git add .'")
|
|
536
|
+
self.assertIn("-C", add_argv)
|
|
537
|
+
# The file argument should end in .md
|
|
538
|
+
file_arg = add_argv[-1]
|
|
539
|
+
self.assertTrue(file_arg.endswith(".md"), f"expected .md path, got: {file_arg}")
|
|
540
|
+
# Commit message should mention the slug
|
|
541
|
+
commit_argv = commit_calls[0]
|
|
542
|
+
msg_idx = commit_argv.index("-m") + 1
|
|
543
|
+
self.assertIn("my-feature", commit_argv[msg_idx])
|
|
544
|
+
|
|
545
|
+
def test_commit_shared_track_path_scoped_not_git_add_dot(self):
|
|
546
|
+
"""The git add call must never use '.' as the file argument."""
|
|
547
|
+
rc, mw, msub, out = self._drive_shared(
|
|
548
|
+
["myrepo", "path-scoped-test", "--commit"]
|
|
549
|
+
)
|
|
550
|
+
self.assertEqual(rc, 0)
|
|
551
|
+
git_cmds = [c[0][0] for c in msub.call_args_list]
|
|
552
|
+
add_calls = [c for c in git_cmds if "add" in c]
|
|
553
|
+
self.assertEqual(len(add_calls), 1)
|
|
554
|
+
self.assertNotIn(".", add_calls[0])
|
|
555
|
+
|
|
556
|
+
def test_commit_private_track_warns_and_skips_git(self):
|
|
557
|
+
"""--commit on a private track (notes_root, not .work-plan) → warning
|
|
558
|
+
printed, git NOT called."""
|
|
559
|
+
cfg = _make_cfg() # no local clone → private route
|
|
560
|
+
|
|
561
|
+
def _path_exists(self):
|
|
562
|
+
if self == Path(NOTES_ROOT):
|
|
563
|
+
return True
|
|
564
|
+
if self.suffix == ".md":
|
|
565
|
+
return False
|
|
566
|
+
return True
|
|
567
|
+
|
|
568
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
569
|
+
patch("commands.new_track.write_file") as mw, \
|
|
570
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
571
|
+
patch("pathlib.Path.exists", _path_exists), \
|
|
572
|
+
patch("pathlib.Path.mkdir"), \
|
|
573
|
+
patch("commands.new_track.subprocess.run") as msub:
|
|
574
|
+
buf = io.StringIO()
|
|
575
|
+
with redirect_stdout(buf):
|
|
576
|
+
rc = new_track.run(["myrepo", "private-track", "--commit"])
|
|
577
|
+
self.assertEqual(rc, 0)
|
|
578
|
+
mw.assert_called_once()
|
|
579
|
+
msub.assert_not_called()
|
|
580
|
+
self.assertIn("--commit ignored", buf.getvalue())
|
|
581
|
+
|
|
582
|
+
def test_commit_git_failure_is_non_fatal(self):
|
|
583
|
+
"""--commit with git add failing → rc still 0, warning printed."""
|
|
584
|
+
rc, mw, msub, out = self._drive_shared(
|
|
585
|
+
["myrepo", "my-feature", "--commit"],
|
|
586
|
+
git_returncode=1,
|
|
587
|
+
)
|
|
588
|
+
self.assertEqual(rc, 0, "git failure must be non-fatal")
|
|
589
|
+
mw.assert_called_once()
|
|
590
|
+
self.assertIn("⚠", out)
|
|
591
|
+
|
|
592
|
+
def test_no_commit_flag_no_git_calls(self):
|
|
593
|
+
"""Without --commit: git is never called, even for a shared track."""
|
|
594
|
+
rc, mw, msub, out = self._drive_shared(["myrepo", "my-feature"])
|
|
595
|
+
self.assertEqual(rc, 0)
|
|
596
|
+
mw.assert_called_once()
|
|
597
|
+
msub.assert_not_called()
|
|
598
|
+
|
|
599
|
+
def test_commit_success_prints_committed_line(self):
|
|
600
|
+
"""Successful --commit → output contains 'committed' and the slug."""
|
|
601
|
+
rc, mw, msub, out = self._drive_shared(
|
|
602
|
+
["myrepo", "my-feature", "--commit"]
|
|
603
|
+
)
|
|
604
|
+
self.assertEqual(rc, 0)
|
|
605
|
+
self.assertIn("committed", out)
|
|
606
|
+
self.assertIn("my-feature", out)
|
|
607
|
+
|
|
608
|
+
|
|
444
609
|
if __name__ == "__main__":
|
|
445
610
|
unittest.main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Tests for lib/notes_readme.py — seed_readme."""
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
9
|
+
|
|
10
|
+
from lib.notes_readme import seed_readme, README_CONTENT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SeedReadmeTest(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
17
|
+
self.work_plan_dir = Path(self._tmp.name) / ".work-plan"
|
|
18
|
+
self.work_plan_dir.mkdir()
|
|
19
|
+
|
|
20
|
+
def tearDown(self):
|
|
21
|
+
self._tmp.cleanup()
|
|
22
|
+
|
|
23
|
+
def test_writes_readme_when_absent_returns_true(self):
|
|
24
|
+
"""seed_readme writes README.md when it doesn't exist; returns True."""
|
|
25
|
+
result = seed_readme(self.work_plan_dir)
|
|
26
|
+
self.assertTrue(result)
|
|
27
|
+
readme = self.work_plan_dir / "README.md"
|
|
28
|
+
self.assertTrue(readme.exists())
|
|
29
|
+
|
|
30
|
+
def test_idempotent_existing_readme_returns_false(self):
|
|
31
|
+
"""seed_readme skips when README.md already exists; returns False."""
|
|
32
|
+
readme = self.work_plan_dir / "README.md"
|
|
33
|
+
readme.write_text("existing content", encoding="utf-8")
|
|
34
|
+
result = seed_readme(self.work_plan_dir)
|
|
35
|
+
self.assertFalse(result)
|
|
36
|
+
# Content not overwritten
|
|
37
|
+
self.assertEqual(readme.read_text(encoding="utf-8"), "existing content")
|
|
38
|
+
|
|
39
|
+
def test_idempotent_second_call_returns_false(self):
|
|
40
|
+
"""Calling seed_readme twice: first call True, second call False."""
|
|
41
|
+
first = seed_readme(self.work_plan_dir)
|
|
42
|
+
second = seed_readme(self.work_plan_dir)
|
|
43
|
+
self.assertTrue(first)
|
|
44
|
+
self.assertFalse(second)
|
|
45
|
+
|
|
46
|
+
def test_readme_content_contains_shared_tier(self):
|
|
47
|
+
"""Written README contains 'shared tier'."""
|
|
48
|
+
seed_readme(self.work_plan_dir)
|
|
49
|
+
content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
|
|
50
|
+
self.assertIn("shared tier", content)
|
|
51
|
+
|
|
52
|
+
def test_readme_content_contains_private_flag(self):
|
|
53
|
+
"""Written README mentions '--private'."""
|
|
54
|
+
seed_readme(self.work_plan_dir)
|
|
55
|
+
content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
|
|
56
|
+
self.assertIn("--private", content)
|
|
57
|
+
|
|
58
|
+
def test_readme_content_contains_work_plan_toolkit(self):
|
|
59
|
+
"""Written README references 'work-plan-toolkit'."""
|
|
60
|
+
seed_readme(self.work_plan_dir)
|
|
61
|
+
content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
|
|
62
|
+
self.assertIn("work-plan-toolkit", content)
|
|
63
|
+
|
|
64
|
+
def test_absent_readme_in_existing_folder_is_written(self):
|
|
65
|
+
"""Caller's responsibility: seed_readme writes when README absent,
|
|
66
|
+
regardless of whether the dir is 'new' or 'existing'.
|
|
67
|
+
(The deletion-as-opt-out contract is enforced by callers who only
|
|
68
|
+
call seed_readme when creating a new directory, not by the function.)
|
|
69
|
+
"""
|
|
70
|
+
# Simulate an existing dir that lost its README (from the function's POV,
|
|
71
|
+
# the file is just absent → it writes)
|
|
72
|
+
result = seed_readme(self.work_plan_dir)
|
|
73
|
+
self.assertTrue(result)
|
|
74
|
+
self.assertTrue((self.work_plan_dir / "README.md").exists())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Non-interactive guard for the prompt helpers (regression test for #183).
|
|
2
|
+
|
|
3
|
+
When `work_plan.py` is launched with stdin wired to a pipe/socket that stays
|
|
4
|
+
open but never delivers a line (the VS Code extension does exactly this),
|
|
5
|
+
`input()` blocks forever — no data, no EOF. The fix makes prompt_input /
|
|
6
|
+
prompt_yes_no / prompt_lines fall back to their default when stdin is not a
|
|
7
|
+
TTY, and only call input() when it is.
|
|
8
|
+
|
|
9
|
+
These tests fake stdin's isatty() rather than touching the real terminal, and
|
|
10
|
+
assert input() is NOT called on the non-TTY path (so a regression that drops
|
|
11
|
+
the guard would deadlock under a real pipe — here it would call the patched
|
|
12
|
+
input and the assertion would fire instead of hanging).
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from unittest import mock
|
|
20
|
+
|
|
21
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
23
|
+
|
|
24
|
+
from lib import prompts
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _FakeStdin:
|
|
28
|
+
def __init__(self, tty: bool):
|
|
29
|
+
self._tty = tty
|
|
30
|
+
|
|
31
|
+
def isatty(self) -> bool:
|
|
32
|
+
return self._tty
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NonInteractiveGuardTest(unittest.TestCase):
|
|
36
|
+
"""No TTY → return the default immediately, never call input()."""
|
|
37
|
+
|
|
38
|
+
def test_prompt_input_returns_default_without_reading(self):
|
|
39
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
40
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
41
|
+
buf = io.StringIO()
|
|
42
|
+
with redirect_stdout(buf):
|
|
43
|
+
out = prompts.prompt_input("Apply? [y/N]", default="N")
|
|
44
|
+
self.assertEqual(out, "N")
|
|
45
|
+
|
|
46
|
+
def test_prompt_input_default_is_empty_string_by_default(self):
|
|
47
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
48
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
49
|
+
buf = io.StringIO()
|
|
50
|
+
with redirect_stdout(buf):
|
|
51
|
+
out = prompts.prompt_input("anything")
|
|
52
|
+
self.assertEqual(out, "")
|
|
53
|
+
|
|
54
|
+
def test_prompt_yes_no_returns_false_without_reading(self):
|
|
55
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
56
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
57
|
+
buf = io.StringIO()
|
|
58
|
+
with redirect_stdout(buf):
|
|
59
|
+
out = prompts.prompt_yes_no()
|
|
60
|
+
self.assertFalse(out)
|
|
61
|
+
|
|
62
|
+
def test_prompt_lines_returns_empty_without_reading(self):
|
|
63
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
64
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
65
|
+
out = prompts.prompt_lines()
|
|
66
|
+
self.assertEqual(out, [])
|
|
67
|
+
|
|
68
|
+
def test_guard_false_when_stdin_is_none(self):
|
|
69
|
+
with mock.patch.object(prompts.sys, "stdin", None):
|
|
70
|
+
self.assertFalse(prompts._stdin_is_interactive())
|
|
71
|
+
|
|
72
|
+
def test_guard_false_when_isatty_raises(self):
|
|
73
|
+
class Broken:
|
|
74
|
+
def isatty(self):
|
|
75
|
+
raise ValueError("I/O operation on closed file")
|
|
76
|
+
with mock.patch.object(prompts.sys, "stdin", Broken()):
|
|
77
|
+
self.assertFalse(prompts._stdin_is_interactive())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InteractivePathStillReadsTest(unittest.TestCase):
|
|
81
|
+
"""With a TTY, the helpers still call input() and honour the reply."""
|
|
82
|
+
|
|
83
|
+
def test_prompt_input_reads_when_tty(self):
|
|
84
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
85
|
+
mock.patch("builtins.input", return_value=" hello "):
|
|
86
|
+
buf = io.StringIO()
|
|
87
|
+
with redirect_stdout(buf):
|
|
88
|
+
out = prompts.prompt_input("q")
|
|
89
|
+
self.assertEqual(out, "hello")
|
|
90
|
+
|
|
91
|
+
def test_prompt_input_blank_reply_falls_back_to_default(self):
|
|
92
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
93
|
+
mock.patch("builtins.input", return_value=" "):
|
|
94
|
+
buf = io.StringIO()
|
|
95
|
+
with redirect_stdout(buf):
|
|
96
|
+
out = prompts.prompt_input("q", default="def")
|
|
97
|
+
self.assertEqual(out, "def")
|
|
98
|
+
|
|
99
|
+
def test_prompt_yes_no_true_only_on_y(self):
|
|
100
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
101
|
+
mock.patch("builtins.input", return_value="Y"):
|
|
102
|
+
buf = io.StringIO()
|
|
103
|
+
with redirect_stdout(buf):
|
|
104
|
+
self.assertTrue(prompts.prompt_yes_no())
|
|
105
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
106
|
+
mock.patch("builtins.input", return_value="n"):
|
|
107
|
+
buf = io.StringIO()
|
|
108
|
+
with redirect_stdout(buf):
|
|
109
|
+
self.assertFalse(prompts.prompt_yes_no())
|
|
110
|
+
|
|
111
|
+
def test_prompt_input_eof_returns_default_when_tty(self):
|
|
112
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
113
|
+
mock.patch("builtins.input", side_effect=EOFError):
|
|
114
|
+
buf = io.StringIO()
|
|
115
|
+
with redirect_stdout(buf):
|
|
116
|
+
out = prompts.prompt_input("q", default="d")
|
|
117
|
+
self.assertEqual(out, "d")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Cross-track auto-move detection in reconcile (#163).
|
|
2
|
+
|
|
3
|
+
When an issue sits in track A's frontmatter but is now labeled for exactly one
|
|
4
|
+
OTHER active track B in the same repo (a relabel), reconcile proposes a MOVE:
|
|
5
|
+
remove from A, add to B — instead of leaving it as a dangling FLAG on A and a
|
|
6
|
+
fresh ADD on B (which would duplicate it across both tracks).
|
|
7
|
+
|
|
8
|
+
All gh calls are mocked; tests run offline. needs_confirm is patched so the
|
|
9
|
+
public-repo gate is exercised without a real `gh repo view`.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
20
|
+
|
|
21
|
+
from commands import reconcile
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _track(*, slug, repo="ok/ok", issues=None):
|
|
25
|
+
return SimpleNamespace(
|
|
26
|
+
name=slug,
|
|
27
|
+
path=Path(f"/tmp/fake/{slug}.md"),
|
|
28
|
+
body="# fake",
|
|
29
|
+
meta={"track": slug, "status": "active",
|
|
30
|
+
"github": {"repo": repo, "issues": list(issues or [])}},
|
|
31
|
+
has_frontmatter=True,
|
|
32
|
+
repo=repo,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Harness:
|
|
37
|
+
"""Drives reconcile --all over a set of tracks with a label→issues map.
|
|
38
|
+
|
|
39
|
+
`labeled` maps a GitHub label string to the list of issue dicts that
|
|
40
|
+
`gh issue/pr list --label <label>` should return.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, tracks, labeled, *, private=True):
|
|
44
|
+
self.tracks = tracks
|
|
45
|
+
self.labeled = labeled
|
|
46
|
+
self.private = private
|
|
47
|
+
self.writes = [] # (path_name, issues) per write_file call
|
|
48
|
+
|
|
49
|
+
def _fake_run(self, argv, *a, **kw):
|
|
50
|
+
out = []
|
|
51
|
+
if "--label" in argv and argv[1] == "issue": # only count issues once
|
|
52
|
+
lab = argv[argv.index("--label") + 1]
|
|
53
|
+
out = self.labeled.get(lab, [])
|
|
54
|
+
return MagicMock(returncode=0, stdout=json.dumps(out), stderr="")
|
|
55
|
+
|
|
56
|
+
def _fake_write(self, path, meta, body):
|
|
57
|
+
self.writes.append((path.name, list(meta.get("github", {}).get("issues") or [])))
|
|
58
|
+
|
|
59
|
+
def run(self, extra_args=None):
|
|
60
|
+
cfg = {"notes_root": "/tmp/n", "repos": {"ok": {"github": "ok/ok"}}}
|
|
61
|
+
with patch("commands.reconcile.subprocess.run", side_effect=self._fake_run), \
|
|
62
|
+
patch("commands.reconcile.load_config", return_value=cfg), \
|
|
63
|
+
patch("commands.reconcile.discover_tracks", return_value=self.tracks), \
|
|
64
|
+
patch("commands.reconcile.needs_confirm", return_value=not self.private), \
|
|
65
|
+
patch("commands.reconcile.write_file", side_effect=self._fake_write), \
|
|
66
|
+
patch("commands.reconcile.prompt_input", return_value="y"):
|
|
67
|
+
rc = reconcile.run(["--all"] + (extra_args or []))
|
|
68
|
+
return rc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AutoMoveTest(unittest.TestCase):
|
|
72
|
+
def test_relabel_moves_issue_from_a_to_b(self):
|
|
73
|
+
# #50 is in alpha's frontmatter but now carries only track/beta.
|
|
74
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
75
|
+
beta = _track(slug="beta", issues=[])
|
|
76
|
+
labeled = {
|
|
77
|
+
"track/alpha": [],
|
|
78
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
79
|
+
}
|
|
80
|
+
h = _Harness([alpha, beta], labeled)
|
|
81
|
+
rc = h.run(extra_args=["--yes"])
|
|
82
|
+
self.assertEqual(rc, 0)
|
|
83
|
+
writes = dict(h.writes)
|
|
84
|
+
self.assertEqual(writes["alpha.md"], []) # removed from source
|
|
85
|
+
self.assertEqual(writes["beta.md"], [50]) # added to destination
|
|
86
|
+
self.assertEqual(len(h.writes), 2) # each side written once
|
|
87
|
+
|
|
88
|
+
def test_ambiguous_target_is_not_moved_out_of_source(self):
|
|
89
|
+
# #50 lost alpha's label and is labeled for BOTH beta and gamma →
|
|
90
|
+
# ambiguous target, so reconcile must NOT move it out of alpha. (beta
|
|
91
|
+
# and gamma each legitimately ADD it, since it carries both labels —
|
|
92
|
+
# that's normal membership-follows-labels behaviour, not a move.) The
|
|
93
|
+
# point of this test: alpha keeps #50, the move logic does not fire.
|
|
94
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
95
|
+
beta = _track(slug="beta", issues=[])
|
|
96
|
+
gamma = _track(slug="gamma", issues=[])
|
|
97
|
+
labeled = {
|
|
98
|
+
"track/alpha": [],
|
|
99
|
+
"track/beta": [{"number": 50, "title": "x", "state": "OPEN"}],
|
|
100
|
+
"track/gamma": [{"number": 50, "title": "x", "state": "OPEN"}],
|
|
101
|
+
}
|
|
102
|
+
h = _Harness([alpha, beta, gamma], labeled)
|
|
103
|
+
rc = h.run(extra_args=["--yes"])
|
|
104
|
+
self.assertEqual(rc, 0)
|
|
105
|
+
writes = dict(h.writes)
|
|
106
|
+
# alpha must NOT be rewritten — #50 stays (no unambiguous move target).
|
|
107
|
+
self.assertNotIn("alpha.md", writes)
|
|
108
|
+
# beta and gamma each ADD #50 (it is labeled for both).
|
|
109
|
+
self.assertEqual(writes.get("beta.md"), [50])
|
|
110
|
+
self.assertEqual(writes.get("gamma.md"), [50])
|
|
111
|
+
|
|
112
|
+
def test_draft_reports_move_but_writes_nothing(self):
|
|
113
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
114
|
+
beta = _track(slug="beta", issues=[])
|
|
115
|
+
labeled = {
|
|
116
|
+
"track/alpha": [],
|
|
117
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
118
|
+
}
|
|
119
|
+
h = _Harness([alpha, beta], labeled)
|
|
120
|
+
rc = h.run(extra_args=["--draft"])
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
self.assertEqual(h.writes, [])
|
|
123
|
+
|
|
124
|
+
def test_public_destination_skipped_under_yes(self):
|
|
125
|
+
# Destination is PUBLIC → under --yes the move is skipped (no silent
|
|
126
|
+
# membership write to a shared track); source is left untouched too.
|
|
127
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
128
|
+
beta = _track(slug="beta", issues=[])
|
|
129
|
+
labeled = {
|
|
130
|
+
"track/alpha": [],
|
|
131
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
132
|
+
}
|
|
133
|
+
h = _Harness([alpha, beta], labeled, private=False)
|
|
134
|
+
rc = h.run(extra_args=["--yes"])
|
|
135
|
+
self.assertEqual(rc, 0)
|
|
136
|
+
self.assertEqual(h.writes, []) # nothing written when dst is public
|
|
137
|
+
|
|
138
|
+
def test_move_does_not_duplicate_as_add_on_destination(self):
|
|
139
|
+
# The destination must NOT also try to ADD #50 (which would be the
|
|
140
|
+
# naive behaviour); it arrives exactly once, via the move.
|
|
141
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
142
|
+
beta = _track(slug="beta", issues=[])
|
|
143
|
+
labeled = {
|
|
144
|
+
"track/alpha": [],
|
|
145
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
146
|
+
}
|
|
147
|
+
h = _Harness([alpha, beta], labeled)
|
|
148
|
+
h.run(extra_args=["--yes"])
|
|
149
|
+
writes = dict(h.writes)
|
|
150
|
+
self.assertEqual(writes["beta.md"].count(50), 1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
unittest.main()
|
|
@@ -14,6 +14,7 @@ verbs. It exercises both the default-label path (no `github.labels`
|
|
|
14
14
|
override) and the new override path from #32.
|
|
15
15
|
"""
|
|
16
16
|
import json
|
|
17
|
+
import subprocess
|
|
17
18
|
import sys
|
|
18
19
|
import unittest
|
|
19
20
|
from pathlib import Path
|
|
@@ -141,6 +142,25 @@ class ReadOnlyContractTest(unittest.TestCase):
|
|
|
141
142
|
self._assert_read_only(captured)
|
|
142
143
|
mock_write.assert_called_once()
|
|
143
144
|
|
|
145
|
+
def test_yes_applies_without_prompt_and_writes_local_only(self):
|
|
146
|
+
# --yes (non-interactive, e.g. from the VS Code extension) applies the
|
|
147
|
+
# proposed ADDs without ever calling prompt_input, and the only write
|
|
148
|
+
# is the local frontmatter file — never gh. This is the #183 fix: a
|
|
149
|
+
# piped/no-TTY run must not hang on the prompt.
|
|
150
|
+
track = _fake_track(slug="epsilon", repo="ok/ok", issues=[5])
|
|
151
|
+
gh_response = [
|
|
152
|
+
{"number": 5, "title": "x", "state": "OPEN"},
|
|
153
|
+
{"number": 99, "title": "new", "state": "OPEN"},
|
|
154
|
+
]
|
|
155
|
+
rc, captured, mock_write, mock_prompt = self._drive(
|
|
156
|
+
track=track, gh_response=gh_response,
|
|
157
|
+
user_choice="n", extra_args=["--yes"],
|
|
158
|
+
)
|
|
159
|
+
self.assertEqual(rc, 0)
|
|
160
|
+
self._assert_read_only(captured)
|
|
161
|
+
mock_prompt.assert_not_called()
|
|
162
|
+
mock_write.assert_called_once()
|
|
163
|
+
|
|
144
164
|
def test_draft_skips_user_prompt_and_write(self):
|
|
145
165
|
# --draft prints the analysis but never prompts and never writes.
|
|
146
166
|
# Even with proposed ADDs (so the report path is exercised), the user
|
|
@@ -162,5 +182,77 @@ class ReadOnlyContractTest(unittest.TestCase):
|
|
|
162
182
|
mock_write.assert_not_called()
|
|
163
183
|
|
|
164
184
|
|
|
185
|
+
def test_timeout_skips_track_but_continues_others(self):
|
|
186
|
+
# When _fetch_labeled_issues raises TimeoutExpired for one track, the
|
|
187
|
+
# track is skipped with a ⚠ warning and the rest of --all continues.
|
|
188
|
+
# Verifies: no crash, warning printed, other tracks still processed.
|
|
189
|
+
track_alpha = _fake_track(slug="alpha", repo="ok/ok", issues=[1])
|
|
190
|
+
track_beta = _fake_track(slug="beta", repo="ok/ok", issues=[10])
|
|
191
|
+
|
|
192
|
+
captured = []
|
|
193
|
+
timed_out_labels = set()
|
|
194
|
+
|
|
195
|
+
def fake_run(argv, *args, **kwargs):
|
|
196
|
+
captured.append(list(argv))
|
|
197
|
+
# alpha's default label is "track/alpha" — time it out
|
|
198
|
+
if "--label" in argv:
|
|
199
|
+
label_idx = argv.index("--label") + 1
|
|
200
|
+
label = argv[label_idx]
|
|
201
|
+
if label == "track/alpha":
|
|
202
|
+
timed_out_labels.add(label)
|
|
203
|
+
raise subprocess.TimeoutExpired(cmd=argv, timeout=15)
|
|
204
|
+
return MagicMock(
|
|
205
|
+
returncode=0,
|
|
206
|
+
stdout=json.dumps([{"number": 10, "title": "x", "state": "OPEN"}]),
|
|
207
|
+
stderr="",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
|
|
211
|
+
with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
|
|
212
|
+
patch("commands.reconcile.load_config", return_value=cfg), \
|
|
213
|
+
patch("commands.reconcile.discover_tracks",
|
|
214
|
+
return_value=[track_alpha, track_beta]), \
|
|
215
|
+
patch("commands.reconcile.prompt_input",
|
|
216
|
+
return_value="n") as mock_prompt, \
|
|
217
|
+
patch("commands.reconcile.write_file") as mock_write:
|
|
218
|
+
rc = reconcile.run(["--all"])
|
|
219
|
+
|
|
220
|
+
self.assertEqual(rc, 0)
|
|
221
|
+
self.assertTrue(timed_out_labels, "alpha label should have timed out")
|
|
222
|
+
# Beta's gh calls should have succeeded
|
|
223
|
+
beta_calls = [a for a in captured
|
|
224
|
+
if "--label" in a and a[a.index("--label") + 1] == "track/beta"]
|
|
225
|
+
self.assertTrue(len(beta_calls) > 0,
|
|
226
|
+
"beta should have been fetched after alpha timeout")
|
|
227
|
+
mock_write.assert_not_called()
|
|
228
|
+
|
|
229
|
+
def test_single_track_timeout_skips_cleanly(self):
|
|
230
|
+
# Even with a single track (the non-parallel code path), a timeout
|
|
231
|
+
# should skip the track with a warning and return 0 without crashing.
|
|
232
|
+
track = _fake_track(slug="lonely", repo="ok/ok", issues=[7])
|
|
233
|
+
|
|
234
|
+
captured = []
|
|
235
|
+
timed_out = False
|
|
236
|
+
|
|
237
|
+
def fake_run(argv, *args, **kwargs):
|
|
238
|
+
captured.append(list(argv))
|
|
239
|
+
nonlocal timed_out
|
|
240
|
+
timed_out = True
|
|
241
|
+
raise subprocess.TimeoutExpired(cmd=argv, timeout=15)
|
|
242
|
+
|
|
243
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
|
|
244
|
+
with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
|
|
245
|
+
patch("commands.reconcile.load_config", return_value=cfg), \
|
|
246
|
+
patch("commands.reconcile.discover_tracks", return_value=[track]), \
|
|
247
|
+
patch("commands.reconcile.prompt_input") as mock_prompt, \
|
|
248
|
+
patch("commands.reconcile.write_file") as mock_write:
|
|
249
|
+
rc = reconcile.run(["lonely"])
|
|
250
|
+
|
|
251
|
+
self.assertEqual(rc, 0)
|
|
252
|
+
self.assertTrue(timed_out)
|
|
253
|
+
mock_prompt.assert_not_called()
|
|
254
|
+
mock_write.assert_not_called()
|
|
255
|
+
|
|
256
|
+
|
|
165
257
|
if __name__ == "__main__":
|
|
166
258
|
unittest.main()
|