@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.
Files changed (53) hide show
  1. package/README.md +91 -13
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +71 -17
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +17 -5
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +39 -7
  17. package/skills/work-plan/commands/init_repo.py +43 -1
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +100 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +19 -6
  23. package/skills/work-plan/commands/set_field.py +17 -7
  24. package/skills/work-plan/commands/slot.py +20 -5
  25. package/skills/work-plan/commands/where_was_i.py +23 -5
  26. package/skills/work-plan/lib/config.py +6 -0
  27. package/skills/work-plan/lib/export_model.py +57 -2
  28. package/skills/work-plan/lib/github_state.py +54 -13
  29. package/skills/work-plan/lib/notes_readme.py +38 -0
  30. package/skills/work-plan/lib/prompts.py +34 -3
  31. package/skills/work-plan/lib/tracks.py +208 -18
  32. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  33. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  34. package/skills/work-plan/tests/test_close_tier.py +166 -0
  35. package/skills/work-plan/tests/test_config_shared.py +57 -0
  36. package/skills/work-plan/tests/test_coverage.py +192 -0
  37. package/skills/work-plan/tests/test_export.py +204 -1
  38. package/skills/work-plan/tests/test_export_command.py +2 -2
  39. package/skills/work-plan/tests/test_github_state.py +52 -14
  40. package/skills/work-plan/tests/test_group_apply.py +411 -0
  41. package/skills/work-plan/tests/test_init_repo.py +128 -0
  42. package/skills/work-plan/tests/test_init_shared.py +185 -0
  43. package/skills/work-plan/tests/test_list_sort.py +162 -0
  44. package/skills/work-plan/tests/test_move.py +240 -0
  45. package/skills/work-plan/tests/test_new_track.py +169 -4
  46. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  47. package/skills/work-plan/tests/test_prompts.py +121 -0
  48. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  49. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  50. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  51. package/skills/work-plan/tests/test_tracks.py +395 -1
  52. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  53. 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", "related_tracks", "last_touched", "last_handoff",
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 test_meta_related_tracks_next_up_blockers_empty(self):
323
- """New track starts with empty related_tracks, next_up, blockers."""
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["related_tracks"], [])
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()