@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,324 @@
|
|
|
1
|
+
"""Tests for the auto-triage subcommand."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from unittest.mock import patch, MagicMock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import auto_triage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Helpers
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def _make_cfg(folder="myrepo", github="org/myrepo"):
|
|
23
|
+
return {
|
|
24
|
+
"notes_root": "/tmp/notes",
|
|
25
|
+
"repos": {folder: {"github": github, "local": f"/tmp/{folder}"}},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_track(name, repo, issue_nums, status="active", slug=None):
|
|
30
|
+
return SimpleNamespace(
|
|
31
|
+
name=name,
|
|
32
|
+
repo=repo,
|
|
33
|
+
has_frontmatter=True,
|
|
34
|
+
path=Path(f"/tmp/notes/{name}.md"),
|
|
35
|
+
body="",
|
|
36
|
+
meta={
|
|
37
|
+
"track": slug or name,
|
|
38
|
+
"status": status,
|
|
39
|
+
"launch_priority": "P2",
|
|
40
|
+
"milestone_alignment": "v1",
|
|
41
|
+
"github": {"repo": repo, "issues": list(issue_nums)},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _open_issues(*numbers):
|
|
47
|
+
return [{"number": n, "title": f"Issue {n}", "state": "OPEN",
|
|
48
|
+
"milestone": None, "labels": []} for n in numbers]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _drive_prepare(args, *, cfg, tracks, open_issues):
|
|
52
|
+
buf = io.StringIO()
|
|
53
|
+
with patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
54
|
+
patch("commands.auto_triage.discover_tracks", return_value=tracks), \
|
|
55
|
+
patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \
|
|
56
|
+
patch("commands.auto_triage._batch_path") as mbatch, \
|
|
57
|
+
patch("commands.auto_triage._answers_path"):
|
|
58
|
+
# Use a real temp file so write_text works
|
|
59
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
60
|
+
mbatch.return_value = Path(f.name)
|
|
61
|
+
with redirect_stdout(buf):
|
|
62
|
+
rc = auto_triage.run(args)
|
|
63
|
+
return rc, buf.getvalue(), mbatch.return_value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _drive_apply(*, cfg, tracks, batch, answers):
|
|
67
|
+
"""Run auto_triage._apply with mocked filesystem and frontmatter calls."""
|
|
68
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
69
|
+
batch_file = Path(tmpdir) / "auto_triage.json"
|
|
70
|
+
answers_file = Path(tmpdir) / "auto_triage.answers.json"
|
|
71
|
+
batch_file.write_text(json.dumps(batch), encoding="utf-8")
|
|
72
|
+
answers_file.write_text(json.dumps(answers), encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
75
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
76
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
77
|
+
patch("commands.auto_triage.discover_tracks", return_value=tracks), \
|
|
78
|
+
patch("commands.auto_triage.parse_file",
|
|
79
|
+
side_effect=lambda p: (tracks[0].meta.copy()
|
|
80
|
+
if tracks else {}, "")) as mparse, \
|
|
81
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
82
|
+
buf = io.StringIO()
|
|
83
|
+
with redirect_stdout(buf):
|
|
84
|
+
rc = auto_triage._apply(cfg)
|
|
85
|
+
return rc, mwrite, buf.getvalue()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Prepare step tests
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class AutoTriagePrepareTest(unittest.TestCase):
|
|
93
|
+
|
|
94
|
+
def test_prints_prompt_with_tracks_and_issues(self):
|
|
95
|
+
cfg = _make_cfg()
|
|
96
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [1, 2])]
|
|
97
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=tracks,
|
|
98
|
+
open_issues=_open_issues(1, 2, 3, 4))
|
|
99
|
+
self.assertEqual(rc, 0)
|
|
100
|
+
self.assertIn("auth-flow", out)
|
|
101
|
+
self.assertIn("Issue 3", out)
|
|
102
|
+
self.assertIn("Issue 4", out)
|
|
103
|
+
# tracked issues should NOT appear in untracked list
|
|
104
|
+
self.assertNotIn("Issue 1", out)
|
|
105
|
+
self.assertNotIn("Issue 2", out)
|
|
106
|
+
|
|
107
|
+
def test_no_untracked_issues_exits_clean(self):
|
|
108
|
+
cfg = _make_cfg()
|
|
109
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [1, 2])]
|
|
110
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=tracks,
|
|
111
|
+
open_issues=_open_issues(1, 2))
|
|
112
|
+
self.assertEqual(rc, 0)
|
|
113
|
+
self.assertIn("full coverage", out)
|
|
114
|
+
|
|
115
|
+
def test_no_active_tracks_exits_with_guidance(self):
|
|
116
|
+
cfg = _make_cfg()
|
|
117
|
+
parked = _make_track("old-track", "org/myrepo", [1], status="parked")
|
|
118
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=[parked],
|
|
119
|
+
open_issues=_open_issues(1, 2))
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
self.assertIn("group", out)
|
|
122
|
+
|
|
123
|
+
def test_multiple_repos_requires_repo_flag(self):
|
|
124
|
+
cfg = {"notes_root": "/tmp", "repos": {
|
|
125
|
+
"repoA": {"github": "org/repoA"},
|
|
126
|
+
"repoB": {"github": "org/repoB"},
|
|
127
|
+
}}
|
|
128
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=[],
|
|
129
|
+
open_issues=[])
|
|
130
|
+
self.assertEqual(rc, 1)
|
|
131
|
+
self.assertIn("Specify with --repo", out)
|
|
132
|
+
|
|
133
|
+
def test_repo_flag_filters_to_one_repo(self):
|
|
134
|
+
cfg = {"notes_root": "/tmp", "repos": {
|
|
135
|
+
"repoA": {"github": "org/repoA"},
|
|
136
|
+
"repoB": {"github": "org/repoB"},
|
|
137
|
+
}}
|
|
138
|
+
tracks = [_make_track("t1", "org/repoA", [1])]
|
|
139
|
+
rc, out, _ = _drive_prepare(["--repo=repoA"], cfg=cfg, tracks=tracks,
|
|
140
|
+
open_issues=_open_issues(1, 2))
|
|
141
|
+
self.assertEqual(rc, 0)
|
|
142
|
+
self.assertIn("repoA", out)
|
|
143
|
+
|
|
144
|
+
def test_unknown_repo_flag_returns_error(self):
|
|
145
|
+
cfg = _make_cfg()
|
|
146
|
+
rc, out, _ = _drive_prepare(["--repo=nope"], cfg=cfg, tracks=[],
|
|
147
|
+
open_issues=[])
|
|
148
|
+
self.assertEqual(rc, 1)
|
|
149
|
+
self.assertIn("ERROR", out)
|
|
150
|
+
|
|
151
|
+
def test_batch_file_written_with_correct_fields(self):
|
|
152
|
+
cfg = _make_cfg()
|
|
153
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [1])]
|
|
154
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
155
|
+
batch_file = Path(tmpdir) / "auto_triage.json"
|
|
156
|
+
buf = io.StringIO()
|
|
157
|
+
with patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
158
|
+
patch("commands.auto_triage.discover_tracks", return_value=tracks), \
|
|
159
|
+
patch("commands.auto_triage.fetch_open_issues",
|
|
160
|
+
return_value=_open_issues(1, 2, 3)), \
|
|
161
|
+
patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
162
|
+
patch("commands.auto_triage._answers_path",
|
|
163
|
+
return_value=Path(tmpdir) / "answers.json"), \
|
|
164
|
+
redirect_stdout(buf):
|
|
165
|
+
auto_triage.run([])
|
|
166
|
+
stored = json.loads(batch_file.read_text())
|
|
167
|
+
self.assertEqual(stored["repo"], "org/myrepo")
|
|
168
|
+
self.assertEqual(stored["folder"], "myrepo")
|
|
169
|
+
self.assertEqual(len(stored["untracked"]), 2) # 1 is tracked
|
|
170
|
+
self.assertEqual(len(stored["tracks"]), 1)
|
|
171
|
+
self.assertEqual(stored["tracks"][0]["slug"], "auth-flow")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Apply step tests
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
class AutoTriageApplyTest(unittest.TestCase):
|
|
179
|
+
|
|
180
|
+
def _simple_batch(self):
|
|
181
|
+
return {
|
|
182
|
+
"repo": "org/myrepo",
|
|
183
|
+
"folder": "myrepo",
|
|
184
|
+
"untracked": [{"number": 3, "title": "Issue 3"},
|
|
185
|
+
{"number": 4, "title": "Issue 4"}],
|
|
186
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def test_apply_slots_issues_into_track(self):
|
|
190
|
+
cfg = _make_cfg()
|
|
191
|
+
track = _make_track("auth-flow", "org/myrepo", [1, 2])
|
|
192
|
+
answers = [{"track": "auth-flow", "issues": [3, 4]}]
|
|
193
|
+
|
|
194
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
195
|
+
batch_file = Path(tmpdir) / "auto_triage.json"
|
|
196
|
+
answers_file = Path(tmpdir) / "auto_triage.answers.json"
|
|
197
|
+
batch_file.write_text(json.dumps(self._simple_batch()))
|
|
198
|
+
answers_file.write_text(json.dumps(answers))
|
|
199
|
+
|
|
200
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
201
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
202
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
203
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
204
|
+
patch("commands.auto_triage.parse_file",
|
|
205
|
+
return_value=(track.meta.copy(), "")) as mparse, \
|
|
206
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
207
|
+
buf = io.StringIO()
|
|
208
|
+
with redirect_stdout(buf):
|
|
209
|
+
rc = auto_triage._apply(cfg)
|
|
210
|
+
|
|
211
|
+
self.assertEqual(rc, 0)
|
|
212
|
+
mwrite.assert_called_once()
|
|
213
|
+
written_meta = mwrite.call_args[0][1]
|
|
214
|
+
self.assertIn(3, written_meta["github"]["issues"])
|
|
215
|
+
self.assertIn(4, written_meta["github"]["issues"])
|
|
216
|
+
|
|
217
|
+
def test_apply_skips_already_tracked_issues(self):
|
|
218
|
+
cfg = _make_cfg()
|
|
219
|
+
track = _make_track("auth-flow", "org/myrepo", [1, 2, 3]) # 3 already there
|
|
220
|
+
answers = [{"track": "auth-flow", "issues": [3, 4]}] # 3 dup, 4 new
|
|
221
|
+
|
|
222
|
+
batch = {
|
|
223
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
224
|
+
"untracked": [{"number": 4, "title": "Issue 4"}],
|
|
225
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
229
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
230
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
231
|
+
batch_file.write_text(json.dumps(batch))
|
|
232
|
+
answers_file.write_text(json.dumps(answers))
|
|
233
|
+
|
|
234
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
235
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
236
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
237
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
238
|
+
patch("commands.auto_triage.parse_file",
|
|
239
|
+
return_value=(track.meta.copy(), "")), \
|
|
240
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
241
|
+
buf = io.StringIO()
|
|
242
|
+
with redirect_stdout(buf):
|
|
243
|
+
rc = auto_triage._apply(cfg)
|
|
244
|
+
|
|
245
|
+
self.assertEqual(rc, 0)
|
|
246
|
+
# write_file called once for issue 4 (issue 3 not in batch untracked → no write)
|
|
247
|
+
# Actually 4 is new, so write_file should be called
|
|
248
|
+
mwrite.assert_called_once()
|
|
249
|
+
out = buf.getvalue()
|
|
250
|
+
self.assertIn("already present", out) # note about #3
|
|
251
|
+
|
|
252
|
+
def test_apply_unknown_track_in_answers_warns_and_skips(self):
|
|
253
|
+
cfg = _make_cfg()
|
|
254
|
+
track = _make_track("auth-flow", "org/myrepo", [1])
|
|
255
|
+
answers = [{"track": "nonexistent-track", "issues": [3]}]
|
|
256
|
+
|
|
257
|
+
batch = {
|
|
258
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
259
|
+
"untracked": [{"number": 3, "title": "Issue 3"}],
|
|
260
|
+
"tracks": [],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
264
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
265
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
266
|
+
batch_file.write_text(json.dumps(batch))
|
|
267
|
+
answers_file.write_text(json.dumps(answers))
|
|
268
|
+
|
|
269
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
270
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
271
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
272
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
273
|
+
patch("commands.auto_triage.parse_file", return_value=({}, "")), \
|
|
274
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
275
|
+
buf = io.StringIO()
|
|
276
|
+
with redirect_stdout(buf):
|
|
277
|
+
rc = auto_triage._apply(cfg)
|
|
278
|
+
|
|
279
|
+
self.assertEqual(rc, 0)
|
|
280
|
+
mwrite.assert_not_called()
|
|
281
|
+
self.assertIn("WARN", buf.getvalue())
|
|
282
|
+
|
|
283
|
+
def test_apply_missing_answers_file_returns_error(self):
|
|
284
|
+
cfg = _make_cfg()
|
|
285
|
+
with patch("commands.auto_triage._answers_path",
|
|
286
|
+
return_value=Path("/nonexistent/answers.json")), \
|
|
287
|
+
patch("commands.auto_triage._batch_path",
|
|
288
|
+
return_value=Path("/nonexistent/batch.json")):
|
|
289
|
+
buf = io.StringIO()
|
|
290
|
+
with redirect_stdout(buf):
|
|
291
|
+
rc = auto_triage._apply(cfg)
|
|
292
|
+
self.assertEqual(rc, 1)
|
|
293
|
+
self.assertIn("ERROR", buf.getvalue())
|
|
294
|
+
|
|
295
|
+
def test_apply_empty_answers_does_nothing(self):
|
|
296
|
+
cfg = _make_cfg()
|
|
297
|
+
track = _make_track("auth-flow", "org/myrepo", [1])
|
|
298
|
+
batch = {
|
|
299
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
300
|
+
"untracked": [{"number": 3, "title": "Issue 3"}],
|
|
301
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
302
|
+
}
|
|
303
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
304
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
305
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
306
|
+
batch_file.write_text(json.dumps(batch))
|
|
307
|
+
answers_file.write_text(json.dumps([]))
|
|
308
|
+
|
|
309
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
310
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
311
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
312
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
313
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
314
|
+
buf = io.StringIO()
|
|
315
|
+
with redirect_stdout(buf):
|
|
316
|
+
rc = auto_triage._apply(cfg)
|
|
317
|
+
|
|
318
|
+
self.assertEqual(rc, 0)
|
|
319
|
+
mwrite.assert_not_called()
|
|
320
|
+
self.assertIn("0 issue(s) assigned", buf.getvalue())
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
unittest.main()
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Tests for the non-interactive close command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- close <track> --state=parked on a PRIVATE repo → status set to parked,
|
|
5
|
+
write_file called, shutil.move NOT called (stays in place), rc 0.
|
|
6
|
+
- --state=shipped on a private repo → status shipped, write_file called,
|
|
7
|
+
shutil.move called to archive/shipped/, rc 0.
|
|
8
|
+
- --state=abandoned → moves to archive/abandoned/.
|
|
9
|
+
- Missing --state → rc 2, no write.
|
|
10
|
+
- Invalid --state=bogus → rc 2, no write.
|
|
11
|
+
- --note="wrapped up" → the body passed to write_file contains the ## Wrap-up
|
|
12
|
+
section with the note.
|
|
13
|
+
- Public repo, no token → prints needs_confirm JSON, no write/move, rc 0;
|
|
14
|
+
token equals make_token(repo, track.name).
|
|
15
|
+
- Public repo with valid --confirm=<token> → performs the close (write/move
|
|
16
|
+
happen), rc 0.
|
|
17
|
+
- No input()/prompt_input is reached on the flagged path (patch them to raise).
|
|
18
|
+
"""
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import unittest
|
|
23
|
+
from contextlib import redirect_stdout
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import SimpleNamespace
|
|
26
|
+
from unittest.mock import patch
|
|
27
|
+
|
|
28
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
29
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
30
|
+
|
|
31
|
+
from commands import close
|
|
32
|
+
from lib.write_guard import make_token
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _track(*, name="alpha", repo="ok/repo", status="active"):
|
|
40
|
+
return SimpleNamespace(
|
|
41
|
+
name=name,
|
|
42
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
43
|
+
body="# fake body",
|
|
44
|
+
meta={
|
|
45
|
+
"track": name,
|
|
46
|
+
"status": status,
|
|
47
|
+
"github": {"repo": repo},
|
|
48
|
+
},
|
|
49
|
+
has_frontmatter=True,
|
|
50
|
+
repo=repo,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _drive(args, track=None, vis="PRIVATE"):
|
|
55
|
+
"""Run close.run(args) with all external I/O mocked.
|
|
56
|
+
|
|
57
|
+
vis controls what repo_visibility returns (used by needs_confirm).
|
|
58
|
+
track defaults to a single private-repo track named 'alpha'.
|
|
59
|
+
"""
|
|
60
|
+
if track is None:
|
|
61
|
+
track = _track()
|
|
62
|
+
# notes_root must be a parent of the track path so relative_to() works
|
|
63
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {"ok": {"github": "ok/repo"}}}
|
|
64
|
+
|
|
65
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
66
|
+
patch("commands.close.discover_tracks", return_value=[track]), \
|
|
67
|
+
patch("commands.close.find_track_by_name", return_value=track), \
|
|
68
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
69
|
+
patch("commands.close.write_file") as mw, \
|
|
70
|
+
patch("commands.close.shutil") as ms, \
|
|
71
|
+
patch("pathlib.Path.mkdir"):
|
|
72
|
+
buf = io.StringIO()
|
|
73
|
+
with redirect_stdout(buf):
|
|
74
|
+
rc = close.run(args)
|
|
75
|
+
return rc, mw, ms, buf.getvalue()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Test cases
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class CloseNonInteractiveTest(unittest.TestCase):
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# State: parked (stays in place)
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def test_parked_private_write_no_move(self):
|
|
89
|
+
"""close <track> --state=parked on PRIVATE repo → write_file called,
|
|
90
|
+
shutil.move NOT called, rc 0."""
|
|
91
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"])
|
|
92
|
+
self.assertEqual(rc, 0)
|
|
93
|
+
mw.assert_called_once()
|
|
94
|
+
# status updated to parked
|
|
95
|
+
written_meta = mw.call_args[0][1]
|
|
96
|
+
self.assertEqual(written_meta["status"], "parked")
|
|
97
|
+
# move must NOT be called for parked
|
|
98
|
+
ms.move.assert_not_called()
|
|
99
|
+
self.assertIn("parked", out)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# State: shipped (moves to archive/shipped/)
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def test_shipped_private_write_and_move(self):
|
|
106
|
+
"""--state=shipped on private repo → write_file called, shutil.move
|
|
107
|
+
called to archive/shipped/, rc 0."""
|
|
108
|
+
rc, mw, ms, out = _drive(["alpha", "--state=shipped"])
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
mw.assert_called_once()
|
|
111
|
+
written_meta = mw.call_args[0][1]
|
|
112
|
+
self.assertEqual(written_meta["status"], "shipped")
|
|
113
|
+
ms.move.assert_called_once()
|
|
114
|
+
# Destination path should contain archive/shipped
|
|
115
|
+
dest_arg = ms.move.call_args[0][1]
|
|
116
|
+
self.assertIn("archive", dest_arg)
|
|
117
|
+
self.assertIn("shipped", dest_arg)
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# State: abandoned (moves to archive/abandoned/)
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def test_abandoned_private_write_and_move(self):
|
|
124
|
+
"""--state=abandoned → moves to archive/abandoned/."""
|
|
125
|
+
rc, mw, ms, out = _drive(["alpha", "--state=abandoned"])
|
|
126
|
+
self.assertEqual(rc, 0)
|
|
127
|
+
mw.assert_called_once()
|
|
128
|
+
written_meta = mw.call_args[0][1]
|
|
129
|
+
self.assertEqual(written_meta["status"], "abandoned")
|
|
130
|
+
ms.move.assert_called_once()
|
|
131
|
+
dest_arg = ms.move.call_args[0][1]
|
|
132
|
+
self.assertIn("archive", dest_arg)
|
|
133
|
+
self.assertIn("abandoned", dest_arg)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Missing / invalid --state
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_missing_state_returns_rc2(self):
|
|
140
|
+
"""Missing --state → rc 2, no write."""
|
|
141
|
+
rc, mw, ms, out = _drive(["alpha"])
|
|
142
|
+
self.assertEqual(rc, 2)
|
|
143
|
+
mw.assert_not_called()
|
|
144
|
+
ms.move.assert_not_called()
|
|
145
|
+
|
|
146
|
+
def test_invalid_state_returns_rc2(self):
|
|
147
|
+
"""Invalid --state=bogus → rc 2, no write."""
|
|
148
|
+
rc, mw, ms, out = _drive(["alpha", "--state=bogus"])
|
|
149
|
+
self.assertEqual(rc, 2)
|
|
150
|
+
mw.assert_not_called()
|
|
151
|
+
ms.move.assert_not_called()
|
|
152
|
+
|
|
153
|
+
def test_missing_track_name_returns_rc2(self):
|
|
154
|
+
"""No positional args at all → rc 2 (usage error)."""
|
|
155
|
+
rc, mw, ms, out = _drive([])
|
|
156
|
+
self.assertEqual(rc, 2)
|
|
157
|
+
mw.assert_not_called()
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# --note flag
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def test_note_appended_to_body(self):
|
|
164
|
+
"""--note='wrapped up' → body passed to write_file contains
|
|
165
|
+
## Wrap-up section with the note."""
|
|
166
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked", "--note=wrapped up"])
|
|
167
|
+
self.assertEqual(rc, 0)
|
|
168
|
+
mw.assert_called_once()
|
|
169
|
+
written_body = mw.call_args[0][2]
|
|
170
|
+
self.assertIn("## Wrap-up", written_body)
|
|
171
|
+
self.assertIn("wrapped up", written_body)
|
|
172
|
+
|
|
173
|
+
def test_no_note_no_wrap_up_section(self):
|
|
174
|
+
"""No --note flag → body does NOT contain ## Wrap-up section."""
|
|
175
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"])
|
|
176
|
+
self.assertEqual(rc, 0)
|
|
177
|
+
mw.assert_called_once()
|
|
178
|
+
written_body = mw.call_args[0][2]
|
|
179
|
+
self.assertNotIn("## Wrap-up", written_body)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Confirm-token gate (public repo)
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
186
|
+
"""Public repo, no token → prints needs_confirm JSON, no write/move,
|
|
187
|
+
rc 0; token equals make_token(repo, track.name)."""
|
|
188
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
189
|
+
rc, mw, ms, out = _drive(["alpha", "--state=shipped"], track=track, vis="PUBLIC")
|
|
190
|
+
self.assertEqual(rc, 0)
|
|
191
|
+
mw.assert_not_called()
|
|
192
|
+
ms.move.assert_not_called()
|
|
193
|
+
data = json.loads(out.strip())
|
|
194
|
+
self.assertTrue(data["needs_confirm"])
|
|
195
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
196
|
+
|
|
197
|
+
def test_public_repo_unknown_visibility_returns_needs_confirm(self):
|
|
198
|
+
"""Unknown visibility (None) → also requires confirm."""
|
|
199
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
200
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"], track=track, vis=None)
|
|
201
|
+
self.assertEqual(rc, 0)
|
|
202
|
+
mw.assert_not_called()
|
|
203
|
+
data = json.loads(out.strip())
|
|
204
|
+
self.assertTrue(data["needs_confirm"])
|
|
205
|
+
|
|
206
|
+
def test_public_repo_with_valid_confirm_performs_close(self):
|
|
207
|
+
"""Public repo with valid --confirm=<token> → performs the close
|
|
208
|
+
(write_file called), rc 0."""
|
|
209
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
210
|
+
tok = make_token("ok/repo", "alpha")
|
|
211
|
+
rc, mw, ms, out = _drive(
|
|
212
|
+
["alpha", "--state=parked", f"--confirm={tok}"],
|
|
213
|
+
track=track, vis="PUBLIC"
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(rc, 0)
|
|
216
|
+
mw.assert_called_once()
|
|
217
|
+
|
|
218
|
+
def test_public_repo_with_valid_confirm_shipped_moves(self):
|
|
219
|
+
"""Public repo with valid --confirm=<token> + --state=shipped →
|
|
220
|
+
write_file AND shutil.move both called, rc 0."""
|
|
221
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
222
|
+
tok = make_token("ok/repo", "alpha")
|
|
223
|
+
rc, mw, ms, out = _drive(
|
|
224
|
+
["alpha", "--state=shipped", f"--confirm={tok}"],
|
|
225
|
+
track=track, vis="PUBLIC"
|
|
226
|
+
)
|
|
227
|
+
self.assertEqual(rc, 0)
|
|
228
|
+
mw.assert_called_once()
|
|
229
|
+
ms.move.assert_called_once()
|
|
230
|
+
|
|
231
|
+
def test_public_repo_wrong_token_blocks_write(self):
|
|
232
|
+
"""Public repo with wrong confirm token → blocked, no write, rc 0."""
|
|
233
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
234
|
+
rc, mw, ms, out = _drive(
|
|
235
|
+
["alpha", "--state=shipped", "--confirm=wrongtoken"],
|
|
236
|
+
track=track, vis="PUBLIC"
|
|
237
|
+
)
|
|
238
|
+
self.assertEqual(rc, 0)
|
|
239
|
+
mw.assert_not_called()
|
|
240
|
+
data = json.loads(out.strip())
|
|
241
|
+
self.assertTrue(data["needs_confirm"])
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# No input() on non-interactive path
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def test_no_input_called_on_flagged_path(self):
|
|
248
|
+
"""Flagged paths never call input() or prompt_input, even when
|
|
249
|
+
--state/--note are provided and the repo is private."""
|
|
250
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
251
|
+
|
|
252
|
+
def _raise(*a, **kw):
|
|
253
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
254
|
+
|
|
255
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
256
|
+
|
|
257
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
258
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
259
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
260
|
+
patch("commands.close.discover_tracks", return_value=[track]), \
|
|
261
|
+
patch("commands.close.find_track_by_name", return_value=track), \
|
|
262
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
263
|
+
patch("commands.close.write_file"), \
|
|
264
|
+
patch("commands.close.shutil"), \
|
|
265
|
+
patch("pathlib.Path.mkdir"):
|
|
266
|
+
buf = io.StringIO()
|
|
267
|
+
with redirect_stdout(buf):
|
|
268
|
+
rc = close.run(["alpha", "--state=parked", "--note=done"])
|
|
269
|
+
self.assertEqual(rc, 0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
unittest.main()
|