@stylusnexus/work-plan 2026.6.9 → 2026.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -13
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +71 -17
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +17 -5
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +39 -7
- package/skills/work-plan/commands/init_repo.py +43 -1
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +100 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +19 -6
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +208 -18
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +128 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +169 -4
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +395 -1
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +38 -18
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
def test_limit_truncates_with_more_issues(self):
|
|
174
|
+
"""When untracked count exceeds --limit, show first N + truncation hint."""
|
|
175
|
+
cfg = _make_cfg()
|
|
176
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
177
|
+
issues = _open_issues(*range(1, 110)) # 109 untracked
|
|
178
|
+
rc, out, _ = _drive_prepare(["--limit=10"], cfg=cfg, tracks=tracks,
|
|
179
|
+
open_issues=issues)
|
|
180
|
+
self.assertEqual(rc, 0)
|
|
181
|
+
self.assertIn("Issue 1", out)
|
|
182
|
+
self.assertIn("Issue 10", out)
|
|
183
|
+
self.assertNotIn("Issue 11", out)
|
|
184
|
+
self.assertIn("and 99 more", out)
|
|
185
|
+
self.assertIn("--limit", out)
|
|
186
|
+
|
|
187
|
+
def test_limit_at_or_below_count_shows_all(self):
|
|
188
|
+
"""When untracked count is within --limit, show all with no truncation."""
|
|
189
|
+
cfg = _make_cfg()
|
|
190
|
+
tracks = [_make_track("auth-flow", "org/myrepo", [])]
|
|
191
|
+
issues = _open_issues(1, 2, 3)
|
|
192
|
+
rc, out, _ = _drive_prepare([], cfg=cfg, tracks=tracks,
|
|
193
|
+
open_issues=issues)
|
|
194
|
+
self.assertEqual(rc, 0)
|
|
195
|
+
self.assertIn("Issue 1", out)
|
|
196
|
+
self.assertIn("Issue 2", out)
|
|
197
|
+
self.assertIn("Issue 3", out)
|
|
198
|
+
self.assertNotIn("more issues", out)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Apply step tests
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
class AutoTriageApplyTest(unittest.TestCase):
|
|
206
|
+
|
|
207
|
+
def _simple_batch(self):
|
|
208
|
+
return {
|
|
209
|
+
"repo": "org/myrepo",
|
|
210
|
+
"folder": "myrepo",
|
|
211
|
+
"untracked": [{"number": 3, "title": "Issue 3"},
|
|
212
|
+
{"number": 4, "title": "Issue 4"}],
|
|
213
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def test_apply_slots_issues_into_track(self):
|
|
217
|
+
cfg = _make_cfg()
|
|
218
|
+
track = _make_track("auth-flow", "org/myrepo", [1, 2])
|
|
219
|
+
answers = [{"track": "auth-flow", "issues": [3, 4]}]
|
|
220
|
+
|
|
221
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
222
|
+
batch_file = Path(tmpdir) / "auto_triage.json"
|
|
223
|
+
answers_file = Path(tmpdir) / "auto_triage.answers.json"
|
|
224
|
+
batch_file.write_text(json.dumps(self._simple_batch()))
|
|
225
|
+
answers_file.write_text(json.dumps(answers))
|
|
226
|
+
|
|
227
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
228
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
229
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
230
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
231
|
+
patch("commands.auto_triage.parse_file",
|
|
232
|
+
return_value=(track.meta.copy(), "")) as mparse, \
|
|
233
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
234
|
+
buf = io.StringIO()
|
|
235
|
+
with redirect_stdout(buf):
|
|
236
|
+
rc = auto_triage._apply(cfg)
|
|
237
|
+
|
|
238
|
+
self.assertEqual(rc, 0)
|
|
239
|
+
mwrite.assert_called_once()
|
|
240
|
+
written_meta = mwrite.call_args[0][1]
|
|
241
|
+
self.assertIn(3, written_meta["github"]["issues"])
|
|
242
|
+
self.assertIn(4, written_meta["github"]["issues"])
|
|
243
|
+
|
|
244
|
+
def test_apply_skips_already_tracked_issues(self):
|
|
245
|
+
cfg = _make_cfg()
|
|
246
|
+
track = _make_track("auth-flow", "org/myrepo", [1, 2, 3]) # 3 already there
|
|
247
|
+
answers = [{"track": "auth-flow", "issues": [3, 4]}] # 3 dup, 4 new
|
|
248
|
+
|
|
249
|
+
batch = {
|
|
250
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
251
|
+
"untracked": [{"number": 4, "title": "Issue 4"}],
|
|
252
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
256
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
257
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
258
|
+
batch_file.write_text(json.dumps(batch))
|
|
259
|
+
answers_file.write_text(json.dumps(answers))
|
|
260
|
+
|
|
261
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
262
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
263
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
264
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
265
|
+
patch("commands.auto_triage.parse_file",
|
|
266
|
+
return_value=(track.meta.copy(), "")), \
|
|
267
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
268
|
+
buf = io.StringIO()
|
|
269
|
+
with redirect_stdout(buf):
|
|
270
|
+
rc = auto_triage._apply(cfg)
|
|
271
|
+
|
|
272
|
+
self.assertEqual(rc, 0)
|
|
273
|
+
# write_file called once for issue 4 (issue 3 not in batch untracked → no write)
|
|
274
|
+
# Actually 4 is new, so write_file should be called
|
|
275
|
+
mwrite.assert_called_once()
|
|
276
|
+
out = buf.getvalue()
|
|
277
|
+
self.assertIn("already present", out) # note about #3
|
|
278
|
+
|
|
279
|
+
def test_apply_unknown_track_in_answers_warns_and_skips(self):
|
|
280
|
+
cfg = _make_cfg()
|
|
281
|
+
track = _make_track("auth-flow", "org/myrepo", [1])
|
|
282
|
+
answers = [{"track": "nonexistent-track", "issues": [3]}]
|
|
283
|
+
|
|
284
|
+
batch = {
|
|
285
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
286
|
+
"untracked": [{"number": 3, "title": "Issue 3"}],
|
|
287
|
+
"tracks": [],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
291
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
292
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
293
|
+
batch_file.write_text(json.dumps(batch))
|
|
294
|
+
answers_file.write_text(json.dumps(answers))
|
|
295
|
+
|
|
296
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
297
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
298
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
299
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
300
|
+
patch("commands.auto_triage.parse_file", return_value=({}, "")), \
|
|
301
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
302
|
+
buf = io.StringIO()
|
|
303
|
+
with redirect_stdout(buf):
|
|
304
|
+
rc = auto_triage._apply(cfg)
|
|
305
|
+
|
|
306
|
+
self.assertEqual(rc, 0)
|
|
307
|
+
mwrite.assert_not_called()
|
|
308
|
+
self.assertIn("WARN", buf.getvalue())
|
|
309
|
+
|
|
310
|
+
def test_apply_missing_answers_file_returns_error(self):
|
|
311
|
+
cfg = _make_cfg()
|
|
312
|
+
with patch("commands.auto_triage._answers_path",
|
|
313
|
+
return_value=Path("/nonexistent/answers.json")), \
|
|
314
|
+
patch("commands.auto_triage._batch_path",
|
|
315
|
+
return_value=Path("/nonexistent/batch.json")):
|
|
316
|
+
buf = io.StringIO()
|
|
317
|
+
with redirect_stdout(buf):
|
|
318
|
+
rc = auto_triage._apply(cfg)
|
|
319
|
+
self.assertEqual(rc, 1)
|
|
320
|
+
self.assertIn("ERROR", buf.getvalue())
|
|
321
|
+
|
|
322
|
+
def test_apply_empty_answers_does_nothing(self):
|
|
323
|
+
cfg = _make_cfg()
|
|
324
|
+
track = _make_track("auth-flow", "org/myrepo", [1])
|
|
325
|
+
batch = {
|
|
326
|
+
"repo": "org/myrepo", "folder": "myrepo",
|
|
327
|
+
"untracked": [{"number": 3, "title": "Issue 3"}],
|
|
328
|
+
"tracks": [{"slug": "auth-flow"}],
|
|
329
|
+
}
|
|
330
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
331
|
+
batch_file = Path(tmpdir) / "b.json"
|
|
332
|
+
answers_file = Path(tmpdir) / "a.json"
|
|
333
|
+
batch_file.write_text(json.dumps(batch))
|
|
334
|
+
answers_file.write_text(json.dumps([]))
|
|
335
|
+
|
|
336
|
+
with patch("commands.auto_triage._batch_path", return_value=batch_file), \
|
|
337
|
+
patch("commands.auto_triage._answers_path", return_value=answers_file), \
|
|
338
|
+
patch("commands.auto_triage.load_config", return_value=cfg), \
|
|
339
|
+
patch("commands.auto_triage.discover_tracks", return_value=[track]), \
|
|
340
|
+
patch("commands.auto_triage.write_file") as mwrite:
|
|
341
|
+
buf = io.StringIO()
|
|
342
|
+
with redirect_stdout(buf):
|
|
343
|
+
rc = auto_triage._apply(cfg)
|
|
344
|
+
|
|
345
|
+
self.assertEqual(rc, 0)
|
|
346
|
+
mwrite.assert_not_called()
|
|
347
|
+
self.assertIn("0 issue(s) assigned", buf.getvalue())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
unittest.main()
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Tests for the non-interactive batch-slot command (issue #140).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Slots multiple new issues into a private-repo track → write_file called once, rc 0.
|
|
5
|
+
- Some issues already present → skipped with note, others slotted.
|
|
6
|
+
- All issues already present → no write, prints skip message.
|
|
7
|
+
- Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
|
|
8
|
+
- Public repo with valid --confirm=<token> → write_file called, rc 0.
|
|
9
|
+
- --move with prior owners → removes issues from sources (consolidated writes).
|
|
10
|
+
- Default / --no-move with prior owners → prior owners NOT modified, note printed.
|
|
11
|
+
- Bad issue number / < 2 positionals → rc 2.
|
|
12
|
+
- Mutually exclusive --move + --no-move → rc 2.
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
from unittest.mock import MagicMock, patch
|
|
21
|
+
|
|
22
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
24
|
+
|
|
25
|
+
from commands import batch_slot
|
|
26
|
+
from lib.write_guard import make_token
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _track(*, name, repo="ok/repo", issues=None, status="active"):
|
|
34
|
+
return SimpleNamespace(
|
|
35
|
+
name=name,
|
|
36
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
37
|
+
body="# fake",
|
|
38
|
+
meta={
|
|
39
|
+
"track": name,
|
|
40
|
+
"status": status,
|
|
41
|
+
"github": {"repo": repo, "issues": list(issues or [])},
|
|
42
|
+
},
|
|
43
|
+
has_frontmatter=True,
|
|
44
|
+
repo=repo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _drive(args, tracks=None, vis="PRIVATE"):
|
|
49
|
+
"""Run batch_slot.run(args) with all external I/O mocked."""
|
|
50
|
+
if tracks is None:
|
|
51
|
+
tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
|
|
52
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
53
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
54
|
+
|
|
55
|
+
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
56
|
+
patch("commands.batch_slot.discover_tracks", return_value=tracks), \
|
|
57
|
+
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
58
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
59
|
+
patch("commands.batch_slot.write_file") as mw:
|
|
60
|
+
buf = io.StringIO()
|
|
61
|
+
with redirect_stdout(buf):
|
|
62
|
+
rc = batch_slot.run(args)
|
|
63
|
+
return rc, mw, buf.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Test cases
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class BatchSlotTest(unittest.TestCase):
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Basic batch slot (private repo)
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def test_slots_multiple_new_issues(self):
|
|
77
|
+
"""Slots multiple new issues into a private-repo track → write_file called, rc 0."""
|
|
78
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10])
|
|
79
|
+
rc, mw, out = _drive(["30", "40", "alpha"], tracks=[track], vis="PRIVATE")
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
mw.assert_called_once() # target written once
|
|
82
|
+
written_meta = mw.call_args[0][1]
|
|
83
|
+
self.assertIn(10, written_meta["github"]["issues"])
|
|
84
|
+
self.assertIn(30, written_meta["github"]["issues"])
|
|
85
|
+
self.assertIn(40, written_meta["github"]["issues"])
|
|
86
|
+
self.assertEqual(sorted(written_meta["github"]["issues"]),
|
|
87
|
+
written_meta["github"]["issues"])
|
|
88
|
+
|
|
89
|
+
def test_skips_already_present_issues(self):
|
|
90
|
+
"""Some issues already present → skipped with note, others slotted."""
|
|
91
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
92
|
+
rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
|
|
93
|
+
self.assertEqual(rc, 0)
|
|
94
|
+
mw.assert_called_once()
|
|
95
|
+
written_meta = mw.call_args[0][1]
|
|
96
|
+
self.assertIn(42, written_meta["github"]["issues"])
|
|
97
|
+
self.assertIn(99, written_meta["github"]["issues"])
|
|
98
|
+
self.assertIn("Skipped", out)
|
|
99
|
+
self.assertIn("42", out)
|
|
100
|
+
self.assertIn("Slotted", out)
|
|
101
|
+
self.assertIn("99", out)
|
|
102
|
+
|
|
103
|
+
def test_all_already_present_no_write(self):
|
|
104
|
+
"""All issues already present → no write, prints skip message."""
|
|
105
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42, 99])
|
|
106
|
+
rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
|
|
107
|
+
self.assertEqual(rc, 0)
|
|
108
|
+
mw.assert_not_called()
|
|
109
|
+
self.assertIn("already in track", out)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Confirm-token gate (public repo)
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
116
|
+
"""Public repo, no token → prints needs_confirm JSON, write_file NOT called."""
|
|
117
|
+
import json
|
|
118
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
119
|
+
rc, mw, out = _drive(["99", "100", "alpha"], tracks=[track], vis="PUBLIC")
|
|
120
|
+
self.assertEqual(rc, 0)
|
|
121
|
+
mw.assert_not_called()
|
|
122
|
+
data = json.loads(out.strip())
|
|
123
|
+
self.assertTrue(data["needs_confirm"])
|
|
124
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
125
|
+
|
|
126
|
+
def test_public_repo_with_valid_confirm_performs_write(self):
|
|
127
|
+
"""Public repo with valid --confirm=<token> → write_file called."""
|
|
128
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
129
|
+
tok = make_token("ok/repo", "alpha")
|
|
130
|
+
rc, mw, out = _drive(
|
|
131
|
+
["99", "100", "alpha", f"--confirm={tok}"],
|
|
132
|
+
tracks=[track], vis="PUBLIC",
|
|
133
|
+
)
|
|
134
|
+
self.assertEqual(rc, 0)
|
|
135
|
+
mw.assert_called_once()
|
|
136
|
+
|
|
137
|
+
def test_public_repo_with_wrong_token_blocks_write(self):
|
|
138
|
+
"""Public repo with wrong confirm token → blocked, no write."""
|
|
139
|
+
import json
|
|
140
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
141
|
+
rc, mw, out = _drive(
|
|
142
|
+
["99", "alpha", "--confirm=badtoken"],
|
|
143
|
+
tracks=[track], vis="PUBLIC",
|
|
144
|
+
)
|
|
145
|
+
self.assertEqual(rc, 0)
|
|
146
|
+
mw.assert_not_called()
|
|
147
|
+
data = json.loads(out.strip())
|
|
148
|
+
self.assertTrue(data["needs_confirm"])
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# --move / --no-move flags
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def test_move_removes_issues_from_prior_owners(self):
|
|
155
|
+
"""--move with prior owners → removes issues from sources, writes all."""
|
|
156
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
|
|
157
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
158
|
+
rc, mw, out = _drive(
|
|
159
|
+
["42", "77", "beta", "--move"],
|
|
160
|
+
tracks=[source, target], vis="PRIVATE",
|
|
161
|
+
)
|
|
162
|
+
self.assertEqual(rc, 0)
|
|
163
|
+
# source + target both written
|
|
164
|
+
self.assertEqual(2, mw.call_count)
|
|
165
|
+
self.assertEqual([], source.meta["github"]["issues"])
|
|
166
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
167
|
+
self.assertIn(77, target.meta["github"]["issues"])
|
|
168
|
+
|
|
169
|
+
def test_move_consolidates_multi_source_removals(self):
|
|
170
|
+
"""Multiple issues from same prior owner → source written once."""
|
|
171
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
|
|
172
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
173
|
+
rc, mw, out = _drive(
|
|
174
|
+
["42", "77", "beta", "--move"],
|
|
175
|
+
tracks=[source, target], vis="PRIVATE",
|
|
176
|
+
)
|
|
177
|
+
self.assertEqual(rc, 0)
|
|
178
|
+
self.assertEqual(2, mw.call_count) # source + target, NOT 3
|
|
179
|
+
# Issues sorted are maintained
|
|
180
|
+
self.assertEqual(sorted(source.meta["github"]["issues"]),
|
|
181
|
+
source.meta["github"]["issues"])
|
|
182
|
+
self.assertEqual(sorted(target.meta["github"]["issues"]),
|
|
183
|
+
target.meta["github"]["issues"])
|
|
184
|
+
|
|
185
|
+
def test_default_no_move_preserves_prior_owners(self):
|
|
186
|
+
"""Default (no --move) → prior owners NOT modified; note printed."""
|
|
187
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
188
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
189
|
+
rc, mw, out = _drive(
|
|
190
|
+
["42", "beta"], tracks=[source, target], vis="PRIVATE",
|
|
191
|
+
)
|
|
192
|
+
self.assertEqual(rc, 0)
|
|
193
|
+
mw.assert_called_once() # only target written
|
|
194
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
195
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
196
|
+
self.assertIn("--move", out)
|
|
197
|
+
|
|
198
|
+
def test_explicit_no_move_preserves_prior_owners(self):
|
|
199
|
+
"""Explicit --no-move behaves same as default."""
|
|
200
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
201
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
202
|
+
rc, mw, out = _drive(
|
|
203
|
+
["42", "beta", "--no-move"],
|
|
204
|
+
tracks=[source, target], vis="PRIVATE",
|
|
205
|
+
)
|
|
206
|
+
self.assertEqual(rc, 0)
|
|
207
|
+
mw.assert_called_once()
|
|
208
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
209
|
+
self.assertIn("--move", out)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Error cases
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def test_less_than_two_positionals_returns_rc2(self):
|
|
216
|
+
"""Fewer than 2 positional args (need at least 1 issue + 1 track) → rc 2."""
|
|
217
|
+
rc, mw, out = _drive(["42"])
|
|
218
|
+
self.assertEqual(rc, 2)
|
|
219
|
+
mw.assert_not_called()
|
|
220
|
+
|
|
221
|
+
def test_no_args_returns_rc2(self):
|
|
222
|
+
"""No positional arguments → rc 2."""
|
|
223
|
+
rc, mw, out = _drive([])
|
|
224
|
+
self.assertEqual(rc, 2)
|
|
225
|
+
mw.assert_not_called()
|
|
226
|
+
|
|
227
|
+
def test_bad_issue_number_returns_rc2(self):
|
|
228
|
+
"""Non-integer in issue position → rc 2."""
|
|
229
|
+
rc, mw, out = _drive(["notanumber", "alpha"])
|
|
230
|
+
self.assertEqual(rc, 2)
|
|
231
|
+
mw.assert_not_called()
|
|
232
|
+
|
|
233
|
+
def test_move_and_no_move_together_returns_rc2(self):
|
|
234
|
+
"""Both --move and --no-move → rc 2."""
|
|
235
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
236
|
+
rc, mw, out = _drive(
|
|
237
|
+
["42", "alpha", "--move", "--no-move"],
|
|
238
|
+
tracks=[track], vis="PRIVATE",
|
|
239
|
+
)
|
|
240
|
+
self.assertEqual(rc, 2)
|
|
241
|
+
mw.assert_not_called()
|
|
242
|
+
self.assertIn("mutually exclusive", out)
|
|
243
|
+
|
|
244
|
+
def test_unknown_track_returns_rc1(self):
|
|
245
|
+
"""Track not found → rc 1."""
|
|
246
|
+
rc, mw, out = _drive(["42", "nonexistent"])
|
|
247
|
+
self.assertEqual(rc, 1)
|
|
248
|
+
mw.assert_not_called()
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Single issue (degenerate case)
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def test_single_issue_works_like_slot(self):
|
|
255
|
+
"""A single issue in batch-slot behaves like regular slot."""
|
|
256
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10])
|
|
257
|
+
rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
|
|
258
|
+
self.assertEqual(rc, 0)
|
|
259
|
+
mw.assert_called_once()
|
|
260
|
+
written_meta = mw.call_args[0][1]
|
|
261
|
+
self.assertIn(42, written_meta["github"]["issues"])
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# No input() on non-interactive paths
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def test_no_input_called_on_flagged_paths(self):
|
|
268
|
+
"""Flagged paths (issue + track given) never call input()."""
|
|
269
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
270
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
271
|
+
|
|
272
|
+
def _raise(*a, **kw):
|
|
273
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
274
|
+
|
|
275
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
276
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
277
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
278
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
279
|
+
with patch("commands.batch_slot.load_config", return_value=cfg), \
|
|
280
|
+
patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
|
|
281
|
+
patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
|
|
282
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
283
|
+
patch("commands.batch_slot.write_file"):
|
|
284
|
+
buf = io.StringIO()
|
|
285
|
+
with redirect_stdout(buf):
|
|
286
|
+
rc = batch_slot.run(["42", "beta", "--move"])
|
|
287
|
+
self.assertEqual(rc, 0)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
unittest.main()
|