@stylusnexus/work-plan 2026.6.9-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tests for the `Suggested first action` line in handoff's fresh-session prompt.
|
|
2
|
+
|
|
3
|
+
Issue #57: the resume hook surfaced `Pick up #4790 from the 'next_up' list.`
|
|
4
|
+
even though #4790 was rendered as `(state: closed)` directly above. The fix
|
|
5
|
+
filters next_up by state and picks the first non-closed entry; if every entry
|
|
6
|
+
is closed, it emits a 'run handoff to rotate' hint instead.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from commands import handoff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FirstActionableNextUpTest(unittest.TestCase):
|
|
20
|
+
def test_returns_first_open_when_leading_entry_closed(self):
|
|
21
|
+
issues_by_num = {
|
|
22
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
23
|
+
4789: {"number": 4789, "state": "OPEN"},
|
|
24
|
+
4788: {"number": 4788, "state": "OPEN"},
|
|
25
|
+
}
|
|
26
|
+
result = handoff._first_actionable_next_up([4790, 4789, 4788], issues_by_num)
|
|
27
|
+
self.assertEqual(result, 4789)
|
|
28
|
+
|
|
29
|
+
def test_returns_first_when_already_open(self):
|
|
30
|
+
issues_by_num = {
|
|
31
|
+
4789: {"number": 4789, "state": "OPEN"},
|
|
32
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
33
|
+
}
|
|
34
|
+
result = handoff._first_actionable_next_up([4789, 4790], issues_by_num)
|
|
35
|
+
self.assertEqual(result, 4789)
|
|
36
|
+
|
|
37
|
+
def test_returns_none_when_every_entry_closed(self):
|
|
38
|
+
issues_by_num = {
|
|
39
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
40
|
+
4791: {"number": 4791, "state": "MERGED"},
|
|
41
|
+
}
|
|
42
|
+
result = handoff._first_actionable_next_up([4790, 4791], issues_by_num)
|
|
43
|
+
self.assertIsNone(result)
|
|
44
|
+
|
|
45
|
+
def test_unknown_issue_is_treated_as_actionable(self):
|
|
46
|
+
result = handoff._first_actionable_next_up([9999], {})
|
|
47
|
+
self.assertEqual(result, 9999)
|
|
48
|
+
|
|
49
|
+
def test_unknown_returned_before_closed(self):
|
|
50
|
+
issues_by_num = {4790: {"number": 4790, "state": "CLOSED"}}
|
|
51
|
+
result = handoff._first_actionable_next_up([4790, 9999], issues_by_num)
|
|
52
|
+
self.assertEqual(result, 9999)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _track_meta(next_up):
|
|
56
|
+
return SimpleNamespace(
|
|
57
|
+
meta={"track": "demo", "launch_priority": "P3", "milestone_alignment": "v1.0.0"},
|
|
58
|
+
repo="org/repo",
|
|
59
|
+
local_path=None,
|
|
60
|
+
path=Path("/tmp/demo.md"),
|
|
61
|
+
name="demo",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BuildFreshSessionPromptTest(unittest.TestCase):
|
|
66
|
+
def test_suggests_first_open_when_leading_next_up_closed(self):
|
|
67
|
+
track = _track_meta(None)
|
|
68
|
+
next_up = [4790, 4789]
|
|
69
|
+
issues_by_num = {
|
|
70
|
+
4790: {"number": 4790, "title": "shipped already", "state": "CLOSED"},
|
|
71
|
+
4789: {"number": 4789, "title": "still open", "state": "OPEN"},
|
|
72
|
+
}
|
|
73
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
74
|
+
track, commits=[], uncommitted=[], last_session="",
|
|
75
|
+
open_items=[], open_source=None,
|
|
76
|
+
next_up=next_up, issues_by_num=issues_by_num,
|
|
77
|
+
repo_wide_commits=0,
|
|
78
|
+
)
|
|
79
|
+
self.assertIn("Pick up #4789 from the `next_up` list.", prompt)
|
|
80
|
+
self.assertNotIn("Pick up #4790 from the `next_up` list.", prompt)
|
|
81
|
+
|
|
82
|
+
def test_emits_rotate_hint_when_all_next_up_closed(self):
|
|
83
|
+
track = _track_meta(None)
|
|
84
|
+
next_up = [4790, 4791]
|
|
85
|
+
issues_by_num = {
|
|
86
|
+
4790: {"number": 4790, "title": "x", "state": "CLOSED"},
|
|
87
|
+
4791: {"number": 4791, "title": "y", "state": "MERGED"},
|
|
88
|
+
}
|
|
89
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
90
|
+
track, commits=[], uncommitted=[], last_session="",
|
|
91
|
+
open_items=[], open_source=None,
|
|
92
|
+
next_up=next_up, issues_by_num=issues_by_num,
|
|
93
|
+
repo_wide_commits=0,
|
|
94
|
+
)
|
|
95
|
+
self.assertIn("All `next_up` items are closed", prompt)
|
|
96
|
+
self.assertIn("/work-plan handoff demo", prompt)
|
|
97
|
+
self.assertNotIn("Pick up #", prompt)
|
|
98
|
+
|
|
99
|
+
def test_uncommitted_takes_precedence_over_next_up(self):
|
|
100
|
+
track = _track_meta(None)
|
|
101
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
102
|
+
track, commits=[], uncommitted=["src/foo.ts"], last_session="",
|
|
103
|
+
open_items=[], open_source=None,
|
|
104
|
+
next_up=[4790], issues_by_num={4790: {"state": "CLOSED"}},
|
|
105
|
+
repo_wide_commits=0,
|
|
106
|
+
)
|
|
107
|
+
self.assertIn("Resume the uncommitted work above.", prompt)
|
|
108
|
+
self.assertNotIn("Pick up #", prompt)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Tests for repo-qualified track resolution (Phase B).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- find_track_by_name: single match, no match, ambiguous (raises AmbiguousTrackError)
|
|
5
|
+
- find_track_by_name with repo=: disambiguates cross-repo same slug
|
|
6
|
+
- parse_track_repo_arg: all split cases
|
|
7
|
+
- close command accepts --repo=<key>
|
|
8
|
+
- handoff command accepts --repo=<key>
|
|
9
|
+
"""
|
|
10
|
+
import io
|
|
11
|
+
import sys
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stdout
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from unittest.mock import patch
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
20
|
+
|
|
21
|
+
from lib.tracks import (
|
|
22
|
+
Track,
|
|
23
|
+
AmbiguousTrackError,
|
|
24
|
+
find_track_by_name,
|
|
25
|
+
parse_track_repo_arg,
|
|
26
|
+
)
|
|
27
|
+
from commands import close, handoff
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Track factory helpers
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _track(name, repo=None, folder=None, status="active"):
|
|
35
|
+
"""Build a minimal Track for testing."""
|
|
36
|
+
return Track(
|
|
37
|
+
path=Path(f"/tmp/notes/{name}.md"),
|
|
38
|
+
name=name,
|
|
39
|
+
has_frontmatter=True,
|
|
40
|
+
needs_init=False,
|
|
41
|
+
needs_filing=False,
|
|
42
|
+
repo=repo,
|
|
43
|
+
folder=folder,
|
|
44
|
+
meta={"track": name, "status": status},
|
|
45
|
+
body="",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# find_track_by_name tests
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class FindTrackByNameTest(unittest.TestCase):
|
|
54
|
+
|
|
55
|
+
def test_single_match_returns_track(self):
|
|
56
|
+
"""One matching track → returned directly."""
|
|
57
|
+
t = _track("feat-x", repo="org/repo")
|
|
58
|
+
result = find_track_by_name("feat-x", [t])
|
|
59
|
+
self.assertIs(result, t)
|
|
60
|
+
|
|
61
|
+
def test_no_match_returns_none(self):
|
|
62
|
+
"""No matching track → returns None."""
|
|
63
|
+
t = _track("feat-x", repo="org/repo")
|
|
64
|
+
result = find_track_by_name("feat-y", [t])
|
|
65
|
+
self.assertIsNone(result)
|
|
66
|
+
|
|
67
|
+
def test_two_matches_raises_ambiguous_error(self):
|
|
68
|
+
"""Same slug across two repos → raises AmbiguousTrackError."""
|
|
69
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
|
|
70
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
|
|
71
|
+
with self.assertRaises(AmbiguousTrackError) as cm:
|
|
72
|
+
find_track_by_name("feat-x", [t1, t2])
|
|
73
|
+
err = cm.exception
|
|
74
|
+
self.assertEqual(err.name, "feat-x")
|
|
75
|
+
self.assertIn(t1, err.candidates)
|
|
76
|
+
self.assertIn(t2, err.candidates)
|
|
77
|
+
self.assertEqual(len(err.candidates), 2)
|
|
78
|
+
|
|
79
|
+
def test_ambiguous_error_message_contains_repos(self):
|
|
80
|
+
"""Error message names both repos and disambiguation hint."""
|
|
81
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
|
|
82
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
|
|
83
|
+
with self.assertRaises(AmbiguousTrackError) as cm:
|
|
84
|
+
find_track_by_name("feat-x", [t1, t2])
|
|
85
|
+
msg = str(cm.exception)
|
|
86
|
+
self.assertIn("repo-a", msg)
|
|
87
|
+
self.assertIn("repo-b", msg)
|
|
88
|
+
self.assertIn("--repo=", msg)
|
|
89
|
+
self.assertIn("@", msg)
|
|
90
|
+
|
|
91
|
+
def test_repo_qualifier_by_github_slug_disambiguates(self):
|
|
92
|
+
"""repo= matching github slug returns the correct track."""
|
|
93
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
|
|
94
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
|
|
95
|
+
result = find_track_by_name("feat-x", [t1, t2], repo="org/repo-a")
|
|
96
|
+
self.assertIs(result, t1)
|
|
97
|
+
|
|
98
|
+
def test_repo_qualifier_by_folder_key_disambiguates(self):
|
|
99
|
+
"""repo= matching folder key (case-insensitive) returns the correct track."""
|
|
100
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
|
|
101
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
|
|
102
|
+
result = find_track_by_name("feat-x", [t1, t2], repo="REPO-B")
|
|
103
|
+
self.assertIs(result, t2)
|
|
104
|
+
|
|
105
|
+
def test_repo_qualifier_no_match_returns_none(self):
|
|
106
|
+
"""repo= that doesn't match any track → None (not ambiguous)."""
|
|
107
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
|
|
108
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
|
|
109
|
+
result = find_track_by_name("feat-x", [t1, t2], repo="nonexistent")
|
|
110
|
+
self.assertIsNone(result)
|
|
111
|
+
|
|
112
|
+
def test_active_only_filters_before_match(self):
|
|
113
|
+
"""active_only=True excludes non-active tracks even when name matches."""
|
|
114
|
+
t_parked = _track("feat-x", repo="org/repo", status="parked")
|
|
115
|
+
t_active = _track("feat-y", repo="org/repo", status="active")
|
|
116
|
+
result = find_track_by_name("feat-x", [t_parked, t_active], active_only=True)
|
|
117
|
+
self.assertIsNone(result)
|
|
118
|
+
|
|
119
|
+
def test_active_only_with_repo_disambiguates(self):
|
|
120
|
+
"""active_only=True + repo= both apply; non-active filtered then repo narrows."""
|
|
121
|
+
t1 = _track("feat-x", repo="org/repo-a", folder="repo-a", status="active")
|
|
122
|
+
t2 = _track("feat-x", repo="org/repo-b", folder="repo-b", status="parked")
|
|
123
|
+
result = find_track_by_name("feat-x", [t1, t2], active_only=True, repo="repo-a")
|
|
124
|
+
self.assertIs(result, t1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# parse_track_repo_arg tests
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
class ParseTrackRepoArgTest(unittest.TestCase):
|
|
132
|
+
|
|
133
|
+
def test_name_at_repo_splits_correctly(self):
|
|
134
|
+
name, repo = parse_track_repo_arg("foo@critforge")
|
|
135
|
+
self.assertEqual(name, "foo")
|
|
136
|
+
self.assertEqual(repo, "critforge")
|
|
137
|
+
|
|
138
|
+
def test_no_at_returns_original_none(self):
|
|
139
|
+
name, repo = parse_track_repo_arg("foo")
|
|
140
|
+
self.assertEqual(name, "foo")
|
|
141
|
+
self.assertIsNone(repo)
|
|
142
|
+
|
|
143
|
+
def test_leading_at_invalid_returns_original_none(self):
|
|
144
|
+
"""@foo has no valid name before @, so returns original arg and None."""
|
|
145
|
+
name, repo = parse_track_repo_arg("@foo")
|
|
146
|
+
self.assertEqual(name, "@foo")
|
|
147
|
+
self.assertIsNone(repo)
|
|
148
|
+
|
|
149
|
+
def test_rpartition_uses_last_at(self):
|
|
150
|
+
"""track@name@repo uses last @ as separator so earlier @ stays in name."""
|
|
151
|
+
name, repo = parse_track_repo_arg("track@name@repo")
|
|
152
|
+
self.assertEqual(name, "track@name")
|
|
153
|
+
self.assertEqual(repo, "repo")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# close command — --repo=<key> accepted and passed through
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _make_close_track(name="alpha", repo="org/repo-a", folder="repo-a"):
|
|
161
|
+
return SimpleNamespace(
|
|
162
|
+
name=name,
|
|
163
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
164
|
+
body="# body",
|
|
165
|
+
meta={"track": name, "status": "active", "github": {"repo": repo}},
|
|
166
|
+
has_frontmatter=True,
|
|
167
|
+
repo=repo,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class CloseRepoFlagTest(unittest.TestCase):
|
|
172
|
+
|
|
173
|
+
def _drive(self, args, find_result, vis="PRIVATE"):
|
|
174
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {}}
|
|
175
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
176
|
+
patch("commands.close.discover_tracks", return_value=[]), \
|
|
177
|
+
patch("commands.close.find_track_by_name", return_value=find_result) as mock_find, \
|
|
178
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
179
|
+
patch("commands.close.write_file"), \
|
|
180
|
+
patch("commands.close.shutil"), \
|
|
181
|
+
patch("pathlib.Path.mkdir"):
|
|
182
|
+
buf = io.StringIO()
|
|
183
|
+
with redirect_stdout(buf):
|
|
184
|
+
rc = close.run(args)
|
|
185
|
+
return rc, mock_find, buf.getvalue()
|
|
186
|
+
|
|
187
|
+
def test_repo_flag_passed_to_find_track(self):
|
|
188
|
+
"""--repo=<key> is extracted and passed as repo= to find_track_by_name."""
|
|
189
|
+
track = _make_close_track()
|
|
190
|
+
rc, mock_find, out = self._drive(
|
|
191
|
+
["alpha", "--state=parked", "--repo=repo-a"],
|
|
192
|
+
find_result=track,
|
|
193
|
+
)
|
|
194
|
+
self.assertEqual(rc, 0)
|
|
195
|
+
call_kwargs = mock_find.call_args
|
|
196
|
+
# find_track_by_name called with repo="repo-a"
|
|
197
|
+
self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
|
|
198
|
+
|
|
199
|
+
def test_at_syntax_extracted_as_repo(self):
|
|
200
|
+
"""alpha@repo-a positional arg → track_name='alpha', repo_qualifier='repo-a'."""
|
|
201
|
+
track = _make_close_track()
|
|
202
|
+
rc, mock_find, out = self._drive(
|
|
203
|
+
["alpha@repo-a", "--state=parked"],
|
|
204
|
+
find_result=track,
|
|
205
|
+
)
|
|
206
|
+
self.assertEqual(rc, 0)
|
|
207
|
+
call_kwargs = mock_find.call_args
|
|
208
|
+
self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
|
|
209
|
+
|
|
210
|
+
def test_ambiguous_error_returns_rc1(self):
|
|
211
|
+
"""AmbiguousTrackError from find_track_by_name → prints message, returns 1."""
|
|
212
|
+
t1 = _track("alpha", repo="org/a", folder="a")
|
|
213
|
+
t2 = _track("alpha", repo="org/b", folder="b")
|
|
214
|
+
err = AmbiguousTrackError("alpha", [t1, t2])
|
|
215
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {}}
|
|
216
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
217
|
+
patch("commands.close.discover_tracks", return_value=[]), \
|
|
218
|
+
patch("commands.close.find_track_by_name", side_effect=err):
|
|
219
|
+
buf = io.StringIO()
|
|
220
|
+
with redirect_stdout(buf):
|
|
221
|
+
rc = close.run(["alpha", "--state=parked"])
|
|
222
|
+
self.assertEqual(rc, 1)
|
|
223
|
+
self.assertIn("ambiguous", buf.getvalue().lower())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# handoff command — --repo=<key> accepted and passed through
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
class HandoffRepoFlagTest(unittest.TestCase):
|
|
231
|
+
|
|
232
|
+
def _drive(self, args, find_result=None):
|
|
233
|
+
"""Drive handoff.run() minimally — mock everything except arg parsing."""
|
|
234
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {}}
|
|
235
|
+
|
|
236
|
+
# Build a minimal track namespace if not supplied
|
|
237
|
+
if find_result is None:
|
|
238
|
+
find_result = SimpleNamespace(
|
|
239
|
+
name="alpha",
|
|
240
|
+
path=Path("/tmp/fake/alpha.md"),
|
|
241
|
+
body="",
|
|
242
|
+
meta={"track": "alpha", "status": "active", "github": {"issues": []}},
|
|
243
|
+
has_frontmatter=True,
|
|
244
|
+
repo="org/repo",
|
|
245
|
+
local_path=None,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
with patch("commands.handoff.load_config", return_value=cfg), \
|
|
249
|
+
patch("commands.handoff.discover_tracks", return_value=[]), \
|
|
250
|
+
patch("commands.handoff.find_track_by_name", return_value=find_result) as mock_find, \
|
|
251
|
+
patch("commands.handoff.fetch_issues", return_value=[]), \
|
|
252
|
+
patch("commands.handoff.write_file"), \
|
|
253
|
+
patch("commands.handoff.append_session_log", return_value=""), \
|
|
254
|
+
patch("commands.handoff.update_row_status", return_value=""), \
|
|
255
|
+
patch("commands.handoff.sync_missing_rows", return_value=("", 0)), \
|
|
256
|
+
patch("commands.handoff.find_new_issues_for_tracks", return_value={}), \
|
|
257
|
+
patch("commands.handoff.has_uncommitted", return_value=False), \
|
|
258
|
+
patch("commands.handoff.current_branch", return_value=None), \
|
|
259
|
+
patch("commands.handoff.uncommitted_file_count", return_value=0), \
|
|
260
|
+
patch("commands.handoff.commits_ahead", return_value=0):
|
|
261
|
+
buf = io.StringIO()
|
|
262
|
+
with redirect_stdout(buf):
|
|
263
|
+
rc = handoff.run(args)
|
|
264
|
+
return rc, mock_find, buf.getvalue()
|
|
265
|
+
|
|
266
|
+
def test_repo_flag_passed_to_find_track(self):
|
|
267
|
+
"""--repo=<key> reaches find_track_by_name as repo= kwarg."""
|
|
268
|
+
rc, mock_find, _ = self._drive(["alpha", "--repo=repo-a"])
|
|
269
|
+
call_kwargs = mock_find.call_args
|
|
270
|
+
self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
|
|
271
|
+
|
|
272
|
+
def test_at_syntax_passed_to_find_track(self):
|
|
273
|
+
"""alpha@repo-a positional → track_name='alpha', repo='repo-a'."""
|
|
274
|
+
rc, mock_find, _ = self._drive(["alpha@repo-a"])
|
|
275
|
+
call_kwargs = mock_find.call_args
|
|
276
|
+
self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
|
|
277
|
+
|
|
278
|
+
def test_ambiguous_error_returns_rc1(self):
|
|
279
|
+
"""AmbiguousTrackError → prints message, returns 1."""
|
|
280
|
+
t1 = _track("alpha", repo="org/a", folder="a")
|
|
281
|
+
t2 = _track("alpha", repo="org/b", folder="b")
|
|
282
|
+
err = AmbiguousTrackError("alpha", [t1, t2])
|
|
283
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {}}
|
|
284
|
+
with patch("commands.handoff.load_config", return_value=cfg), \
|
|
285
|
+
patch("commands.handoff.discover_tracks", return_value=[]), \
|
|
286
|
+
patch("commands.handoff.find_track_by_name", side_effect=err):
|
|
287
|
+
buf = io.StringIO()
|
|
288
|
+
with redirect_stdout(buf):
|
|
289
|
+
rc = handoff.run(["alpha"])
|
|
290
|
+
self.assertEqual(rc, 1)
|
|
291
|
+
self.assertIn("ambiguous", buf.getvalue().lower())
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
unittest.main()
|