@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,348 @@
|
|
|
1
|
+
"""Tests for group --apply tier-aware routing — Phase C."""
|
|
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 unittest.mock import patch, MagicMock, call
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from commands import group
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def _make_cfg(*, notes_root, repo_entry=None):
|
|
22
|
+
if repo_entry is None:
|
|
23
|
+
repo_entry = {
|
|
24
|
+
"github": "org/myrepo",
|
|
25
|
+
"local": "/home/user/projects/myrepo",
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
"notes_root": str(notes_root),
|
|
29
|
+
"repos": {"myrepo": repo_entry},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_batch(*, repo="org/myrepo", folder="myrepo", milestone="v1.0",
|
|
34
|
+
private=False, issues=None):
|
|
35
|
+
if issues is None:
|
|
36
|
+
issues = [
|
|
37
|
+
{"number": 1, "title": "Issue one", "milestone": None,
|
|
38
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
39
|
+
{"number": 2, "title": "Issue two", "milestone": None,
|
|
40
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
41
|
+
]
|
|
42
|
+
return {
|
|
43
|
+
"repo": repo, "folder": folder, "milestone": milestone,
|
|
44
|
+
"private": private, "issues": issues,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _make_answers(slug="auth-flow", name="Auth Flow", summary="Auth stuff",
|
|
49
|
+
issues=None):
|
|
50
|
+
if issues is None:
|
|
51
|
+
issues = [1, 2]
|
|
52
|
+
return [{"slug": slug, "name": name, "summary": summary, "issues": issues}]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _drive_apply(args, *, cfg, batch, answers, vis="PRIVATE"):
|
|
56
|
+
"""Run group._apply with mocked filesystem and gh calls.
|
|
57
|
+
|
|
58
|
+
Uses real temp files for batch/answers (so Path.exists() on them works),
|
|
59
|
+
and patches Path.exists only for track paths (the per-cluster slug files).
|
|
60
|
+
"""
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
62
|
+
# Write batch and answers to REAL temp files
|
|
63
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
64
|
+
answers_file = Path(tmpdir) / "groups.answers.json"
|
|
65
|
+
batch_file.write_text(json.dumps(batch), encoding="utf-8")
|
|
66
|
+
answers_file.write_text(json.dumps(answers), encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
# Track the path that _apply will try to write slug.md files to
|
|
69
|
+
# For shared route: <local>/.work-plan/<slug>.md
|
|
70
|
+
# For private route: notes_root/folder/<slug>.md
|
|
71
|
+
# We need Path.exists() to return False for those track files but
|
|
72
|
+
# True for the batch/answers files that already exist on disk.
|
|
73
|
+
# Solution: only patch Path.exists for paths that don't actually exist.
|
|
74
|
+
|
|
75
|
+
with patch("commands.group._batch_path", return_value=batch_file), \
|
|
76
|
+
patch("commands.group._answers_path", return_value=answers_file), \
|
|
77
|
+
patch("commands.group.load_config", return_value=cfg), \
|
|
78
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
79
|
+
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
80
|
+
patch("commands.group.write_file") as mw, \
|
|
81
|
+
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
82
|
+
patch("commands.group.seed_readme") as mseed, \
|
|
83
|
+
patch("pathlib.Path.mkdir"):
|
|
84
|
+
# Patch Path.exists to return True for the batch/answers files,
|
|
85
|
+
# False for track files (slug.md paths that don't exist yet).
|
|
86
|
+
_real_exists = Path.exists
|
|
87
|
+
|
|
88
|
+
def _selective_exists(self):
|
|
89
|
+
# Real files on disk → use real check
|
|
90
|
+
if str(self) in (str(batch_file), str(answers_file)):
|
|
91
|
+
return True
|
|
92
|
+
# Track directory for shared route: let it appear to exist
|
|
93
|
+
# so _apply doesn't error out trying to mkdir and fails
|
|
94
|
+
# Use Path.name (not endswith) so Windows backslash paths match too.
|
|
95
|
+
_name = Path(self).name
|
|
96
|
+
if _name == ".work-plan" or _name == "myrepo":
|
|
97
|
+
return True
|
|
98
|
+
# Track .md files: pretend they don't exist (trigger create path)
|
|
99
|
+
if str(self).endswith(".md"):
|
|
100
|
+
return False
|
|
101
|
+
# Everything else: real check
|
|
102
|
+
return _real_exists(self)
|
|
103
|
+
|
|
104
|
+
with patch("pathlib.Path.exists", _selective_exists):
|
|
105
|
+
buf = io.StringIO()
|
|
106
|
+
with redirect_stdout(buf):
|
|
107
|
+
rc = group._apply(cfg, args)
|
|
108
|
+
return rc, mw, mseed, buf.getvalue()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Tests
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class GroupApplyTierRoutingTest(unittest.TestCase):
|
|
116
|
+
|
|
117
|
+
def test_apply_with_valid_clone_routes_to_work_plan_dir(self):
|
|
118
|
+
"""group --apply with a valid clone routes track to .work-plan/<slug>.md."""
|
|
119
|
+
notes_root = "/tmp/fake-notes"
|
|
120
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
121
|
+
batch = _make_batch()
|
|
122
|
+
answers = _make_answers()
|
|
123
|
+
|
|
124
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
125
|
+
answers=answers, vis="PRIVATE")
|
|
126
|
+
self.assertEqual(rc, 0)
|
|
127
|
+
mw.assert_called_once()
|
|
128
|
+
written_path = mw.call_args[0][0]
|
|
129
|
+
# Path should be under .work-plan/, not notes_root
|
|
130
|
+
self.assertIn(".work-plan", str(written_path))
|
|
131
|
+
self.assertNotIn("fake-notes", str(written_path))
|
|
132
|
+
|
|
133
|
+
def test_apply_private_flag_routes_to_notes_root(self):
|
|
134
|
+
"""group --apply --private routes to notes_root/folder/<slug>.md."""
|
|
135
|
+
notes_root = "/tmp/fake-notes"
|
|
136
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
137
|
+
batch = _make_batch(private=False) # Not private in batch
|
|
138
|
+
answers = _make_answers()
|
|
139
|
+
|
|
140
|
+
# But --private in args overrides
|
|
141
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
142
|
+
cfg=cfg, batch=batch,
|
|
143
|
+
answers=answers, vis="PRIVATE")
|
|
144
|
+
self.assertEqual(rc, 0)
|
|
145
|
+
mw.assert_called_once()
|
|
146
|
+
written_path = mw.call_args[0][0]
|
|
147
|
+
# Path should NOT be under .work-plan/
|
|
148
|
+
self.assertNotIn(".work-plan", str(written_path))
|
|
149
|
+
|
|
150
|
+
def test_apply_private_in_batch_routes_to_notes_root(self):
|
|
151
|
+
"""group --apply with private=True stored in batch routes to notes_root."""
|
|
152
|
+
notes_root = "/tmp/fake-notes"
|
|
153
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
154
|
+
batch = _make_batch(private=True) # Private stored in batch
|
|
155
|
+
answers = _make_answers()
|
|
156
|
+
|
|
157
|
+
# No --private in args, but batch says private
|
|
158
|
+
rc, mw, mseed, out = _drive_apply(["--apply"],
|
|
159
|
+
cfg=cfg, batch=batch,
|
|
160
|
+
answers=answers, vis="PRIVATE")
|
|
161
|
+
self.assertEqual(rc, 0)
|
|
162
|
+
mw.assert_called_once()
|
|
163
|
+
written_path = mw.call_args[0][0]
|
|
164
|
+
self.assertNotIn(".work-plan", str(written_path))
|
|
165
|
+
|
|
166
|
+
def test_apply_shared_route_seeds_readme_when_dir_is_new(self):
|
|
167
|
+
"""group --apply seeds README only when .work-plan/ is newly created."""
|
|
168
|
+
notes_root = "/tmp/fake-notes"
|
|
169
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
170
|
+
batch = _make_batch()
|
|
171
|
+
answers = _make_answers()
|
|
172
|
+
|
|
173
|
+
# Make the .work-plan dir appear to NOT exist so the mkdir+seed path runs
|
|
174
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
175
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
176
|
+
answers_file = Path(tmpdir) / "groups.answers.json"
|
|
177
|
+
batch_file.write_text(json.dumps(batch), encoding="utf-8")
|
|
178
|
+
answers_file.write_text(json.dumps(answers), encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
_real_exists = Path.exists
|
|
181
|
+
|
|
182
|
+
def _exists_dir_missing(self):
|
|
183
|
+
if str(self) in (str(batch_file), str(answers_file)):
|
|
184
|
+
return True
|
|
185
|
+
if str(self).endswith(".work-plan"):
|
|
186
|
+
return False # dir not yet created → triggers mkdir+seed
|
|
187
|
+
if str(self).endswith(".md"):
|
|
188
|
+
return False
|
|
189
|
+
return _real_exists(self)
|
|
190
|
+
|
|
191
|
+
with patch("commands.group._batch_path", return_value=batch_file), \
|
|
192
|
+
patch("commands.group._answers_path", return_value=answers_file), \
|
|
193
|
+
patch("commands.group.load_config", return_value=cfg), \
|
|
194
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
195
|
+
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
196
|
+
patch("commands.group.write_file"), \
|
|
197
|
+
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
198
|
+
patch("commands.group.seed_readme") as mseed, \
|
|
199
|
+
patch("pathlib.Path.mkdir"), \
|
|
200
|
+
patch("pathlib.Path.exists", _exists_dir_missing):
|
|
201
|
+
group._apply(cfg, [])
|
|
202
|
+
|
|
203
|
+
mseed.assert_called_once()
|
|
204
|
+
seeded_path = mseed.call_args[0][0]
|
|
205
|
+
self.assertIn(".work-plan", str(seeded_path))
|
|
206
|
+
|
|
207
|
+
def test_apply_shared_route_no_readme_resurrection(self):
|
|
208
|
+
"""group --apply does NOT call seed_readme when .work-plan/ already exists."""
|
|
209
|
+
notes_root = "/tmp/fake-notes"
|
|
210
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
211
|
+
batch = _make_batch()
|
|
212
|
+
answers = _make_answers()
|
|
213
|
+
|
|
214
|
+
# Default _drive_apply mocks .work-plan as existing → seed_readme not called
|
|
215
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
216
|
+
answers=answers, vis="PRIVATE")
|
|
217
|
+
self.assertEqual(rc, 0)
|
|
218
|
+
mseed.assert_not_called()
|
|
219
|
+
|
|
220
|
+
def test_apply_private_route_does_not_seed_readme(self):
|
|
221
|
+
"""group --apply --private does NOT call seed_readme."""
|
|
222
|
+
notes_root = "/tmp/fake-notes"
|
|
223
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
224
|
+
batch = _make_batch()
|
|
225
|
+
answers = _make_answers()
|
|
226
|
+
|
|
227
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
228
|
+
cfg=cfg, batch=batch,
|
|
229
|
+
answers=answers, vis="PRIVATE")
|
|
230
|
+
self.assertEqual(rc, 0)
|
|
231
|
+
mseed.assert_not_called()
|
|
232
|
+
|
|
233
|
+
def test_apply_shared_route_public_repo_prints_headsup(self):
|
|
234
|
+
"""group --apply on a public repo → heads-up printed, non-blocking."""
|
|
235
|
+
notes_root = "/tmp/fake-notes"
|
|
236
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
237
|
+
batch = _make_batch()
|
|
238
|
+
answers = _make_answers()
|
|
239
|
+
|
|
240
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
241
|
+
answers=answers, vis="PUBLIC")
|
|
242
|
+
# Non-blocking: rc 0 and write_file still called
|
|
243
|
+
self.assertEqual(rc, 0)
|
|
244
|
+
mw.assert_called_once()
|
|
245
|
+
self.assertIn("HEADS-UP", out)
|
|
246
|
+
self.assertIn("PUBLIC", out)
|
|
247
|
+
|
|
248
|
+
def test_apply_shared_route_unknown_vis_prints_headsup(self):
|
|
249
|
+
"""group --apply with unknown visibility → heads-up printed."""
|
|
250
|
+
notes_root = "/tmp/fake-notes"
|
|
251
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
252
|
+
batch = _make_batch()
|
|
253
|
+
answers = _make_answers()
|
|
254
|
+
|
|
255
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
256
|
+
answers=answers, vis=None)
|
|
257
|
+
self.assertEqual(rc, 0)
|
|
258
|
+
mw.assert_called_once()
|
|
259
|
+
self.assertIn("HEADS-UP", out)
|
|
260
|
+
|
|
261
|
+
def test_apply_shared_route_new_track_prints_shared_hint(self):
|
|
262
|
+
"""group --apply shared route → new track file gets commit+push hint."""
|
|
263
|
+
notes_root = "/tmp/fake-notes"
|
|
264
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
265
|
+
batch = _make_batch()
|
|
266
|
+
answers = _make_answers()
|
|
267
|
+
|
|
268
|
+
rc, mw, mseed, out = _drive_apply([], cfg=cfg, batch=batch,
|
|
269
|
+
answers=answers, vis="PRIVATE")
|
|
270
|
+
self.assertEqual(rc, 0)
|
|
271
|
+
self.assertIn("shared", out)
|
|
272
|
+
self.assertIn("commit + push", out)
|
|
273
|
+
|
|
274
|
+
def test_apply_private_route_no_shared_hint(self):
|
|
275
|
+
"""group --apply --private → no commit+push hint."""
|
|
276
|
+
notes_root = "/tmp/fake-notes"
|
|
277
|
+
cfg = _make_cfg(notes_root=notes_root)
|
|
278
|
+
batch = _make_batch()
|
|
279
|
+
answers = _make_answers()
|
|
280
|
+
|
|
281
|
+
rc, mw, mseed, out = _drive_apply(["--apply", "--private"],
|
|
282
|
+
cfg=cfg, batch=batch,
|
|
283
|
+
answers=answers, vis="PRIVATE")
|
|
284
|
+
self.assertEqual(rc, 0)
|
|
285
|
+
# No shared hint on the private route
|
|
286
|
+
self.assertNotIn("commit + push", out)
|
|
287
|
+
|
|
288
|
+
def test_prepare_step_stores_private_flag_in_batch(self):
|
|
289
|
+
"""group (prepare step) with --private stores 'private': True in batch JSON."""
|
|
290
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
291
|
+
notes_root = Path(tmpdir) / "notes"
|
|
292
|
+
notes_root.mkdir()
|
|
293
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
294
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
295
|
+
|
|
296
|
+
issues = [
|
|
297
|
+
{"number": 1, "title": "T1", "milestone": None,
|
|
298
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
302
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
303
|
+
patch("commands.group._answers_path",
|
|
304
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
305
|
+
patch("subprocess.run") as mock_run:
|
|
306
|
+
mock_run.return_value = MagicMock(
|
|
307
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
308
|
+
)
|
|
309
|
+
buf = io.StringIO()
|
|
310
|
+
with redirect_stdout(buf):
|
|
311
|
+
rc = group.run(["--repo=myrepo", "--private"])
|
|
312
|
+
|
|
313
|
+
self.assertEqual(rc, 0)
|
|
314
|
+
stored = json.loads(batch_file.read_text())
|
|
315
|
+
self.assertTrue(stored.get("private"))
|
|
316
|
+
|
|
317
|
+
def test_prepare_step_without_private_flag_stores_false(self):
|
|
318
|
+
"""group (prepare) without --private stores 'private': False in batch."""
|
|
319
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
320
|
+
notes_root = Path(tmpdir) / "notes"
|
|
321
|
+
notes_root.mkdir()
|
|
322
|
+
cfg = _make_cfg(notes_root=str(notes_root))
|
|
323
|
+
batch_file = Path(tmpdir) / "groups.json"
|
|
324
|
+
|
|
325
|
+
issues = [
|
|
326
|
+
{"number": 1, "title": "T1", "milestone": None,
|
|
327
|
+
"labels": [], "assignees": [], "state": "OPEN"},
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
with patch("commands.group.load_config", return_value=cfg), \
|
|
331
|
+
patch("commands.group._batch_path", return_value=batch_file), \
|
|
332
|
+
patch("commands.group._answers_path",
|
|
333
|
+
return_value=Path(tmpdir) / "groups.answers.json"), \
|
|
334
|
+
patch("subprocess.run") as mock_run:
|
|
335
|
+
mock_run.return_value = MagicMock(
|
|
336
|
+
returncode=0, stdout=json.dumps(issues), stderr=""
|
|
337
|
+
)
|
|
338
|
+
buf = io.StringIO()
|
|
339
|
+
with redirect_stdout(buf):
|
|
340
|
+
rc = group.run(["--repo=myrepo"])
|
|
341
|
+
|
|
342
|
+
self.assertEqual(rc, 0)
|
|
343
|
+
stored = json.loads(batch_file.read_text())
|
|
344
|
+
self.assertFalse(stored.get("private"))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
unittest.main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tests that handoff self-heals canonical-table drift (issue #77).
|
|
2
|
+
|
|
3
|
+
handoff updates existing rows' status via update_row_status but, before #77,
|
|
4
|
+
never appended rows for frontmatter issues missing from the table. These tests
|
|
5
|
+
drive the derived handoff path (git skipped via local_path=None + no prior
|
|
6
|
+
handoff) and assert the missing rows are appended.
|
|
7
|
+
"""
|
|
8
|
+
import io
|
|
9
|
+
import sys
|
|
10
|
+
import unittest
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
18
|
+
|
|
19
|
+
from commands import handoff
|
|
20
|
+
from lib.status_table import find_canonical_status_tables, ISSUE_NUM_RE
|
|
21
|
+
|
|
22
|
+
CANON_HEADER = (
|
|
23
|
+
"## Issues (canonical)\n\n"
|
|
24
|
+
"<!-- canonical-issue-table — auto-managed. -->\n\n"
|
|
25
|
+
"| # | Title | Assignee | Status |\n"
|
|
26
|
+
"|---|---|---|---|\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _track():
|
|
31
|
+
body = (CANON_HEADER
|
|
32
|
+
+ "| #1 | first | — | 🔲 Open |\n"
|
|
33
|
+
+ "| #2 | second | — | ✅ Shipped |\n"
|
|
34
|
+
+ "\n---\n\n## Notes\n\nnarrative\n")
|
|
35
|
+
return SimpleNamespace(
|
|
36
|
+
name="platform-health",
|
|
37
|
+
path=Path("/tmp/fake/platform-health.md"),
|
|
38
|
+
body=body,
|
|
39
|
+
meta={"track": "platform-health", "status": "active",
|
|
40
|
+
"github": {"repo": "o/r", "issues": [1, 2, 30, 40]}},
|
|
41
|
+
has_frontmatter=True,
|
|
42
|
+
repo="o/r",
|
|
43
|
+
local_path=None, # skips all git attribution paths
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _issue(num, title, state="OPEN"):
|
|
48
|
+
return {"number": num, "title": title, "state": state, "assignees": []}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HandoffAppendTest(unittest.TestCase):
|
|
52
|
+
def test_derived_handoff_appends_missing_rows(self):
|
|
53
|
+
track = _track()
|
|
54
|
+
issues = [_issue(1, "first"), _issue(2, "second", "CLOSED"),
|
|
55
|
+
_issue(30, "third"), _issue(40, "fourth", "CLOSED")]
|
|
56
|
+
with patch("commands.handoff.fetch_issues", return_value=issues), \
|
|
57
|
+
patch("commands.handoff.write_file") as mw:
|
|
58
|
+
buf = io.StringIO()
|
|
59
|
+
with redirect_stdout(buf):
|
|
60
|
+
rc = handoff._derived_handoff(track)
|
|
61
|
+
|
|
62
|
+
self.assertEqual(rc, 0)
|
|
63
|
+
mw.assert_called_once()
|
|
64
|
+
new_body = mw.call_args[0][2]
|
|
65
|
+
table = find_canonical_status_tables(new_body)[0]
|
|
66
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
67
|
+
self.assertEqual(nums, [1, 2, 30, 40])
|
|
68
|
+
self.assertIn("| #30 | third | — | 🔲 Open |", new_body)
|
|
69
|
+
self.assertIn("| #40 | fourth | — | ✅ Shipped |", new_body)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tests for handoff commit-attribution helpers: path-glob attribution
|
|
2
|
+
(`github.paths`) and the repo-wide commit counter that drives the soft
|
|
3
|
+
'silence is expected' signal.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from commands import handoff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _track(meta_github, local_path="/tmp/repo"):
|
|
20
|
+
return SimpleNamespace(
|
|
21
|
+
meta={"github": meta_github},
|
|
22
|
+
local_path=local_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _proc(stdout="", returncode=0):
|
|
27
|
+
return SimpleNamespace(stdout=stdout, returncode=returncode, stderr="")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SINCE = datetime(2026, 4, 29, 0, 0, 0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RecentCommitsPathGlobsTest(unittest.TestCase):
|
|
34
|
+
def test_path_glob_attributes_commit_with_no_issue_ref(self):
|
|
35
|
+
"""A commit whose subject doesn't mention any tracked issue but
|
|
36
|
+
whose changed paths match `github.paths` should be attributed."""
|
|
37
|
+
log_output = (
|
|
38
|
+
"---COMMIT---\nabc1234|fix(useToast): debounce stacking|2026-04-30T10:00:00+00:00\n"
|
|
39
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
40
|
+
"apps/web/src/hooks/useToast.tsx\napps/web/src/hooks/useToast.test.tsx\n\n"
|
|
41
|
+
"---COMMIT---\ndef5678|chore: bump deps|2026-04-30T09:00:00+00:00\n"
|
|
42
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
43
|
+
"package.json\n"
|
|
44
|
+
)
|
|
45
|
+
track = _track({
|
|
46
|
+
"issues": [4148, 4149],
|
|
47
|
+
"paths": ["apps/web/src/hooks/useToast*"],
|
|
48
|
+
})
|
|
49
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
50
|
+
return_value=_proc(stdout=log_output)):
|
|
51
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
52
|
+
self.assertEqual(len(commits), 1)
|
|
53
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
54
|
+
|
|
55
|
+
def test_issue_ref_still_attributes_when_paths_set(self):
|
|
56
|
+
"""Issue-ref attribution and path attribution are an OR, not AND."""
|
|
57
|
+
log_output = (
|
|
58
|
+
"---COMMIT---\nabc1234|fix #4148: tighten guardrails|2026-04-30T10:00:00+00:00\n"
|
|
59
|
+
"---BODY---\n\n---ENDBODY---\n"
|
|
60
|
+
"infra/iam/policy.tf\n"
|
|
61
|
+
)
|
|
62
|
+
track = _track({
|
|
63
|
+
"issues": [4148],
|
|
64
|
+
"paths": ["apps/web/src/hooks/useToast*"],
|
|
65
|
+
})
|
|
66
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
67
|
+
return_value=_proc(stdout=log_output)):
|
|
68
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
69
|
+
self.assertEqual(len(commits), 1)
|
|
70
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
71
|
+
|
|
72
|
+
def test_body_issue_ref_attributes_squash_merge_commit(self):
|
|
73
|
+
"""Squash-merged PRs use Conventional Commit subjects with the issue
|
|
74
|
+
ref in the body (e.g. 'Closes #4148'). Subject scanning alone misses
|
|
75
|
+
these; body scanning must catch them."""
|
|
76
|
+
log_output = (
|
|
77
|
+
"---COMMIT---\nabc1234|feat(adventure): cache regen prompts|2026-04-30T10:00:00+00:00\n"
|
|
78
|
+
"---BODY---\n"
|
|
79
|
+
"Reduce duplicate LLM calls when artifacts regenerate.\n"
|
|
80
|
+
"\n"
|
|
81
|
+
"Closes #4148\n"
|
|
82
|
+
"---ENDBODY---\n"
|
|
83
|
+
)
|
|
84
|
+
track = _track({"issues": [4148]})
|
|
85
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
86
|
+
return_value=_proc(stdout=log_output)):
|
|
87
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
88
|
+
self.assertEqual(len(commits), 1)
|
|
89
|
+
self.assertEqual(commits[0]["sha"], "abc1234")
|
|
90
|
+
|
|
91
|
+
def test_body_ref_to_untracked_issue_does_not_attribute(self):
|
|
92
|
+
"""A body that references an issue NOT in github.issues must not
|
|
93
|
+
attribute — otherwise any commit citing any issue would get picked up."""
|
|
94
|
+
log_output = (
|
|
95
|
+
"---COMMIT---\nabc1234|chore: unrelated|2026-04-30T10:00:00+00:00\n"
|
|
96
|
+
"---BODY---\nCloses #9999\n---ENDBODY---\n"
|
|
97
|
+
)
|
|
98
|
+
track = _track({"issues": [4148]})
|
|
99
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
100
|
+
return_value=_proc(stdout=log_output)):
|
|
101
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
102
|
+
self.assertEqual(commits, [])
|
|
103
|
+
|
|
104
|
+
def test_no_paths_no_issues_returns_empty(self):
|
|
105
|
+
"""A track with neither tracked issues nor path globs gets nothing."""
|
|
106
|
+
track = _track({"issues": [], "paths": []})
|
|
107
|
+
with mock.patch("commands.handoff.subprocess.run") as run:
|
|
108
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
109
|
+
run.assert_not_called()
|
|
110
|
+
self.assertEqual(commits, [])
|
|
111
|
+
|
|
112
|
+
def test_explicit_branches_skip_path_globs(self):
|
|
113
|
+
"""When `github.branches` is set, paths do not apply (explicit
|
|
114
|
+
branches are the contract)."""
|
|
115
|
+
log_output = "abc1234|merge: feature work|2026-04-30T10:00:00+00:00"
|
|
116
|
+
track = _track({
|
|
117
|
+
"issues": [4148],
|
|
118
|
+
"branches": ["feature/x"],
|
|
119
|
+
"paths": ["should-not-apply/**"],
|
|
120
|
+
})
|
|
121
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
122
|
+
return_value=_proc(stdout=log_output)) as run:
|
|
123
|
+
commits = handoff._recent_commits(track, SINCE)
|
|
124
|
+
args = run.call_args.args[0]
|
|
125
|
+
self.assertIn("feature/x", args)
|
|
126
|
+
self.assertNotIn("--name-only", args)
|
|
127
|
+
self.assertEqual(len(commits), 1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RepoCommitsSinceTest(unittest.TestCase):
|
|
131
|
+
def test_counts_lines_in_log_output(self):
|
|
132
|
+
out = "sha1\nsha2\nsha3\n"
|
|
133
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
134
|
+
return_value=_proc(stdout=out)):
|
|
135
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
136
|
+
self.assertEqual(n, 3)
|
|
137
|
+
|
|
138
|
+
def test_returns_zero_on_empty(self):
|
|
139
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
140
|
+
return_value=_proc(stdout="")):
|
|
141
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
142
|
+
self.assertEqual(n, 0)
|
|
143
|
+
|
|
144
|
+
def test_returns_zero_on_failure(self):
|
|
145
|
+
with mock.patch("commands.handoff.subprocess.run",
|
|
146
|
+
return_value=_proc(stdout="", returncode=128)):
|
|
147
|
+
n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
|
|
148
|
+
self.assertEqual(n, 0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
unittest.main()
|