@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,243 @@
|
|
|
1
|
+
"""Tests for the non-interactive slot command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Slots a new issue into a private-repo track → write_file called, rc 0.
|
|
5
|
+
- Issue already present → no write, rc 0, prints "already in".
|
|
6
|
+
- Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
|
|
7
|
+
- Public repo with valid --confirm=<token> → write_file called, rc 0.
|
|
8
|
+
- --move with a prior owner → removes issue from prior owner (two writes).
|
|
9
|
+
- Default / --no-move with a prior owner → prior owner NOT modified (one write)
|
|
10
|
+
and "still listed … --move" note is printed.
|
|
11
|
+
- Bad issue number / no positional → rc 2.
|
|
12
|
+
- No input() is reached on the non-interactive flagged paths.
|
|
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 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 slot.run(args) with all external I/O mocked.
|
|
50
|
+
|
|
51
|
+
vis controls what repo_visibility returns (used by needs_confirm).
|
|
52
|
+
tracks defaults to a single empty private-repo track named 'alpha'.
|
|
53
|
+
"""
|
|
54
|
+
if tracks is None:
|
|
55
|
+
tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
|
|
56
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
57
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
58
|
+
|
|
59
|
+
with patch("commands.slot.load_config", return_value=cfg), \
|
|
60
|
+
patch("commands.slot.discover_tracks", return_value=tracks), \
|
|
61
|
+
patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
62
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
63
|
+
patch("commands.slot.write_file") as mw:
|
|
64
|
+
buf = io.StringIO()
|
|
65
|
+
with redirect_stdout(buf):
|
|
66
|
+
rc = slot.run(args)
|
|
67
|
+
return rc, mw, buf.getvalue()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Test cases
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class SlotNonInteractiveTest(unittest.TestCase):
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Basic slot (private repo)
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def test_slots_new_issue_into_private_track(self):
|
|
81
|
+
"""Slots a new issue into a private-repo track → write_file called, rc 0."""
|
|
82
|
+
track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
|
|
83
|
+
rc, mw, out = _drive(["30", "alpha"], tracks=[track], vis="PRIVATE")
|
|
84
|
+
self.assertEqual(rc, 0)
|
|
85
|
+
mw.assert_called_once()
|
|
86
|
+
written_meta = mw.call_args[0][1]
|
|
87
|
+
self.assertIn(30, written_meta["github"]["issues"])
|
|
88
|
+
# Issues are sorted
|
|
89
|
+
self.assertEqual(sorted(written_meta["github"]["issues"]),
|
|
90
|
+
written_meta["github"]["issues"])
|
|
91
|
+
|
|
92
|
+
def test_already_present_no_write(self):
|
|
93
|
+
"""Issue already present → no write, rc 0, prints 'already in'."""
|
|
94
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
95
|
+
rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
|
|
96
|
+
self.assertEqual(rc, 0)
|
|
97
|
+
mw.assert_not_called()
|
|
98
|
+
self.assertIn("already in", out)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Confirm-token gate (public repo)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
105
|
+
"""Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0."""
|
|
106
|
+
import json
|
|
107
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
108
|
+
rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis="PUBLIC")
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
mw.assert_not_called()
|
|
111
|
+
# Output should be parseable JSON with needs_confirm key
|
|
112
|
+
data = json.loads(out.strip())
|
|
113
|
+
self.assertTrue(data["needs_confirm"])
|
|
114
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
115
|
+
|
|
116
|
+
def test_public_repo_unknown_visibility_returns_needs_confirm_json(self):
|
|
117
|
+
"""Unknown visibility (None) → also requires confirm."""
|
|
118
|
+
import json
|
|
119
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
120
|
+
rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis=None)
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
mw.assert_not_called()
|
|
123
|
+
data = json.loads(out.strip())
|
|
124
|
+
self.assertTrue(data["needs_confirm"])
|
|
125
|
+
|
|
126
|
+
def test_public_repo_with_valid_confirm_performs_write(self):
|
|
127
|
+
"""Public repo with valid --confirm=<token> → write_file called, rc 0."""
|
|
128
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
129
|
+
tok = make_token("ok/repo", "alpha")
|
|
130
|
+
rc, mw, out = _drive(["99", "alpha", f"--confirm={tok}"],
|
|
131
|
+
tracks=[track], vis="PUBLIC")
|
|
132
|
+
self.assertEqual(rc, 0)
|
|
133
|
+
mw.assert_called_once()
|
|
134
|
+
|
|
135
|
+
def test_public_repo_with_wrong_token_blocks_write(self):
|
|
136
|
+
"""Public repo with wrong confirm token → blocked, no write."""
|
|
137
|
+
import json
|
|
138
|
+
track = _track(name="alpha", repo="ok/repo", issues=[])
|
|
139
|
+
rc, mw, out = _drive(["99", "alpha", "--confirm=badtoken"],
|
|
140
|
+
tracks=[track], vis="PUBLIC")
|
|
141
|
+
self.assertEqual(rc, 0)
|
|
142
|
+
mw.assert_not_called()
|
|
143
|
+
data = json.loads(out.strip())
|
|
144
|
+
self.assertTrue(data["needs_confirm"])
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# --move / --no-move flags
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def test_move_flag_removes_issue_from_prior_owner(self):
|
|
151
|
+
"""--move with a prior owner → removes issue from source, writes both."""
|
|
152
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
153
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
154
|
+
rc, mw, out = _drive(["42", "beta", "--move"],
|
|
155
|
+
tracks=[source, target], vis="PRIVATE")
|
|
156
|
+
self.assertEqual(rc, 0)
|
|
157
|
+
self.assertEqual(2, mw.call_count,
|
|
158
|
+
"source + target should both be written with --move")
|
|
159
|
+
self.assertNotIn(42, source.meta["github"]["issues"])
|
|
160
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
161
|
+
|
|
162
|
+
def test_default_no_move_preserves_prior_owner(self):
|
|
163
|
+
"""Default (no flags) with a prior owner → prior owner NOT modified; note printed."""
|
|
164
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
165
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
166
|
+
rc, mw, out = _drive(["42", "beta"], tracks=[source, target], vis="PRIVATE")
|
|
167
|
+
self.assertEqual(rc, 0)
|
|
168
|
+
mw.assert_called_once() # only target written
|
|
169
|
+
# Source is untouched
|
|
170
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
171
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
172
|
+
# Note about --move must be printed
|
|
173
|
+
self.assertIn("--move", out)
|
|
174
|
+
self.assertIn("alpha", out)
|
|
175
|
+
|
|
176
|
+
def test_explicit_no_move_preserves_prior_owner(self):
|
|
177
|
+
"""Explicit --no-move behaves same as default: prior owner NOT modified."""
|
|
178
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
179
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
180
|
+
rc, mw, out = _drive(["42", "beta", "--no-move"],
|
|
181
|
+
tracks=[source, target], vis="PRIVATE")
|
|
182
|
+
self.assertEqual(rc, 0)
|
|
183
|
+
mw.assert_called_once()
|
|
184
|
+
self.assertIn(42, source.meta["github"]["issues"])
|
|
185
|
+
self.assertIn(42, target.meta["github"]["issues"])
|
|
186
|
+
self.assertIn("--move", out)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Error cases
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def test_no_positional_args_returns_rc2(self):
|
|
193
|
+
"""No positional arguments → rc 2 (usage error)."""
|
|
194
|
+
rc, mw, out = _drive([])
|
|
195
|
+
self.assertEqual(rc, 2)
|
|
196
|
+
mw.assert_not_called()
|
|
197
|
+
|
|
198
|
+
def test_bad_issue_number_returns_rc2(self):
|
|
199
|
+
"""Non-integer issue number → rc 2."""
|
|
200
|
+
rc, mw, out = _drive(["notanumber", "alpha"])
|
|
201
|
+
self.assertEqual(rc, 2)
|
|
202
|
+
mw.assert_not_called()
|
|
203
|
+
|
|
204
|
+
def test_move_and_no_move_together_returns_rc2(self):
|
|
205
|
+
"""Passing both --move and --no-move → rc 2, write_file NOT called."""
|
|
206
|
+
track = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
207
|
+
rc, mw, out = _drive(["42", "alpha", "--move", "--no-move"], tracks=[track], vis="PRIVATE")
|
|
208
|
+
self.assertEqual(rc, 2)
|
|
209
|
+
mw.assert_not_called()
|
|
210
|
+
self.assertIn("ERROR", out)
|
|
211
|
+
self.assertIn("mutually exclusive", out)
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# No input() on non-interactive paths
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def test_no_input_called_on_flagged_paths(self):
|
|
218
|
+
"""Flagged paths (issue + track given) never call input() even if
|
|
219
|
+
prior owners exist or a public repo is detected."""
|
|
220
|
+
source = _track(name="alpha", repo="ok/repo", issues=[42])
|
|
221
|
+
target = _track(name="beta", repo="ok/repo", issues=[])
|
|
222
|
+
|
|
223
|
+
def _raise(*a, **kw):
|
|
224
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
225
|
+
|
|
226
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
227
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
228
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
229
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
230
|
+
with patch("commands.slot.load_config", return_value=cfg), \
|
|
231
|
+
patch("commands.slot.discover_tracks", return_value=[source, target]), \
|
|
232
|
+
patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
233
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
234
|
+
patch("commands.slot.write_file"):
|
|
235
|
+
buf = io.StringIO()
|
|
236
|
+
with redirect_stdout(buf):
|
|
237
|
+
# --move (prior owner path) + private repo (no confirm gate)
|
|
238
|
+
rc = slot.run(["42", "beta", "--move"])
|
|
239
|
+
self.assertEqual(rc, 0)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
unittest.main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tests for slot's prior-ownership detection (issue #62).
|
|
2
|
+
|
|
3
|
+
Before #62, `slot` was add-only — running `slot 4562 chat-nlu` while #4562 was
|
|
4
|
+
already listed in `ai-generators.md` frontmatter would leave the issue in BOTH
|
|
5
|
+
tracks. The only fix was hand-editing YAML, which SKILL.md explicitly warns
|
|
6
|
+
against. These tests pin the non-interactive behavior introduced in #87:
|
|
7
|
+
detect prior ownership, and move only on explicit --move so non-interactive
|
|
8
|
+
runs preserve add-only semantics by default.
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from unittest.mock import MagicMock, patch
|
|
15
|
+
|
|
16
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
18
|
+
|
|
19
|
+
from commands import slot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _track(*, name, repo, issues, status="active"):
|
|
23
|
+
return SimpleNamespace(
|
|
24
|
+
name=name,
|
|
25
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
26
|
+
body="# fake",
|
|
27
|
+
meta={
|
|
28
|
+
"track": name,
|
|
29
|
+
"status": status,
|
|
30
|
+
"github": {"repo": repo, "issues": list(issues)},
|
|
31
|
+
},
|
|
32
|
+
has_frontmatter=True,
|
|
33
|
+
repo=repo,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SlotMoveTest(unittest.TestCase):
|
|
38
|
+
def _drive(self, *, tracks, args):
|
|
39
|
+
cfg = {"notes_root": "/tmp/fake-notes",
|
|
40
|
+
"repos": {"ok": {"github": "ok/ok"}}}
|
|
41
|
+
gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
|
|
42
|
+
with patch("commands.slot.subprocess.run", return_value=gh_proc), \
|
|
43
|
+
patch("commands.slot.load_config", return_value=cfg), \
|
|
44
|
+
patch("commands.slot.discover_tracks", return_value=tracks), \
|
|
45
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
46
|
+
patch("commands.slot.write_file") as mw:
|
|
47
|
+
rc = slot.run(args)
|
|
48
|
+
return rc, mw
|
|
49
|
+
|
|
50
|
+
def test_no_prior_ownership_writes_only_target(self):
|
|
51
|
+
target = _track(name="alpha", repo="ok/ok", issues=[])
|
|
52
|
+
rc, mw = self._drive(tracks=[target], args=["100", "alpha"])
|
|
53
|
+
self.assertEqual(rc, 0)
|
|
54
|
+
mw.assert_called_once()
|
|
55
|
+
self.assertEqual(100, target.meta["github"]["issues"][0])
|
|
56
|
+
|
|
57
|
+
def test_prior_ownership_with_move_flag_removes_from_source(self):
|
|
58
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
59
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
60
|
+
rc, mw = self._drive(
|
|
61
|
+
tracks=[source, target], args=["42", "beta", "--move"],
|
|
62
|
+
)
|
|
63
|
+
self.assertEqual(rc, 0)
|
|
64
|
+
self.assertEqual(2, mw.call_count, "source + target should both be written")
|
|
65
|
+
self.assertEqual([], source.meta["github"]["issues"])
|
|
66
|
+
self.assertEqual([42], target.meta["github"]["issues"])
|
|
67
|
+
|
|
68
|
+
def test_prior_ownership_without_move_flag_preserves_add_only(self):
|
|
69
|
+
# Default (no --move flag) → pre-#62 behavior: target gets the issue,
|
|
70
|
+
# source is untouched (duplicated state that reconcile can later FLAG).
|
|
71
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
72
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
73
|
+
rc, mw = self._drive(
|
|
74
|
+
tracks=[source, target], args=["42", "beta"],
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(rc, 0)
|
|
77
|
+
mw.assert_called_once()
|
|
78
|
+
self.assertEqual([42], source.meta["github"]["issues"])
|
|
79
|
+
self.assertEqual([42], target.meta["github"]["issues"])
|
|
80
|
+
|
|
81
|
+
def test_explicit_no_move_flag_preserves_add_only(self):
|
|
82
|
+
# --no-move behaves identically to the default.
|
|
83
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
84
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
85
|
+
rc, mw = self._drive(
|
|
86
|
+
tracks=[source, target], args=["42", "beta", "--no-move"],
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(rc, 0)
|
|
89
|
+
mw.assert_called_once()
|
|
90
|
+
self.assertEqual([42], source.meta["github"]["issues"])
|
|
91
|
+
|
|
92
|
+
def test_already_in_target_short_circuits(self):
|
|
93
|
+
# Issue already listed in target → no write.
|
|
94
|
+
source = _track(name="alpha", repo="ok/ok", issues=[42])
|
|
95
|
+
target = _track(name="beta", repo="ok/ok", issues=[42])
|
|
96
|
+
rc, mw = self._drive(
|
|
97
|
+
tracks=[source, target], args=["42", "beta"],
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual(rc, 0)
|
|
100
|
+
mw.assert_not_called()
|
|
101
|
+
|
|
102
|
+
def test_cross_repo_issue_not_detected_as_prior_owner(self):
|
|
103
|
+
# Same number in a different repo is a different issue. The prior-
|
|
104
|
+
# owner sweep MUST filter by track.repo or it will spuriously offer
|
|
105
|
+
# to move unrelated issues across repos.
|
|
106
|
+
other_repo = _track(name="alpha", repo="other/repo", issues=[42])
|
|
107
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
108
|
+
rc, mw = self._drive(
|
|
109
|
+
tracks=[other_repo, target], args=["42", "beta"],
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(rc, 0)
|
|
112
|
+
mw.assert_called_once()
|
|
113
|
+
self.assertEqual([42], other_repo.meta["github"]["issues"])
|
|
114
|
+
|
|
115
|
+
def test_inactive_source_not_detected_as_prior_owner(self):
|
|
116
|
+
# Archived/parked tracks shouldn't be candidates — moving FROM a
|
|
117
|
+
# closed track is the wrong mental model; that's a reopen, not a slot.
|
|
118
|
+
parked = _track(name="alpha", repo="ok/ok", issues=[42], status="parked")
|
|
119
|
+
target = _track(name="beta", repo="ok/ok", issues=[])
|
|
120
|
+
rc, mw = self._drive(
|
|
121
|
+
tracks=[parked, target], args=["42", "beta"],
|
|
122
|
+
)
|
|
123
|
+
self.assertEqual(rc, 0)
|
|
124
|
+
self.assertEqual([42], parked.meta["github"]["issues"])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
unittest.main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Smoke test: importable + main() exists."""
|
|
2
|
+
import io
|
|
3
|
+
import unittest
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
10
|
+
|
|
11
|
+
import work_plan
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SmokeTest(unittest.TestCase):
|
|
15
|
+
def test_main_exists(self):
|
|
16
|
+
self.assertTrue(callable(work_plan.main))
|
|
17
|
+
|
|
18
|
+
def test_main_no_args_returns_2(self):
|
|
19
|
+
self.assertEqual(work_plan.main(["work_plan.py"]), 2)
|
|
20
|
+
|
|
21
|
+
def test_version_loads_from_file(self):
|
|
22
|
+
# VERSION lives at the repo root in source mode and next to work_plan.py
|
|
23
|
+
# in installed mode. The loader walks upward; either layout must produce
|
|
24
|
+
# a non-empty string. Guards against future moves of the VERSION file.
|
|
25
|
+
v = work_plan._load_version()
|
|
26
|
+
self.assertTrue(v, "VERSION file unreachable from work_plan.py")
|
|
27
|
+
self.assertNotEqual(v, "unknown",
|
|
28
|
+
"VERSION file present but resolved to 'unknown' fallback")
|
|
29
|
+
|
|
30
|
+
def test_version_flag_prints_and_exits_zero(self):
|
|
31
|
+
for flag in ("--version", "-v"):
|
|
32
|
+
with self.subTest(flag=flag):
|
|
33
|
+
out_buf = io.StringIO()
|
|
34
|
+
err_buf = io.StringIO()
|
|
35
|
+
with redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
36
|
+
rc = work_plan.main(["work_plan.py", flag])
|
|
37
|
+
out = out_buf.getvalue().strip()
|
|
38
|
+
err = err_buf.getvalue()
|
|
39
|
+
self.assertEqual(rc, 0)
|
|
40
|
+
self.assertTrue(out, f"{flag} produced empty stdout")
|
|
41
|
+
self.assertIn(work_plan.VERSION, out)
|
|
42
|
+
self.assertEqual(err, "", f"{flag} wrote to stderr: {err!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
unittest.main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for idempotent status-header stamping."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.status_header import BEGIN, END, render_block, stamp
|
|
10
|
+
|
|
11
|
+
ROW = {
|
|
12
|
+
"glyph": "✅", "verdict": "shipped",
|
|
13
|
+
"files_present": 9, "files_declared": 9,
|
|
14
|
+
"last_touched": "2026-04-02",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RenderBlockTest(unittest.TestCase):
|
|
19
|
+
def test_block_is_delimited_and_evidence_only(self):
|
|
20
|
+
block = render_block(ROW)
|
|
21
|
+
self.assertTrue(block.startswith(BEGIN))
|
|
22
|
+
self.assertTrue(block.rstrip().endswith(END))
|
|
23
|
+
self.assertIn("shipped", block)
|
|
24
|
+
self.assertIn("9/9 files", block)
|
|
25
|
+
self.assertIn("2026-04-02", block)
|
|
26
|
+
|
|
27
|
+
def test_none_last_touched_renders_unknown(self):
|
|
28
|
+
row = dict(ROW, last_touched=None)
|
|
29
|
+
self.assertIn("unknown", render_block(row))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StampTest(unittest.TestCase):
|
|
33
|
+
DOC = "# My Plan\n\nSome body text.\n"
|
|
34
|
+
|
|
35
|
+
def test_inserts_after_h1(self):
|
|
36
|
+
out = stamp(self.DOC, ROW)
|
|
37
|
+
self.assertIn(BEGIN, out)
|
|
38
|
+
self.assertLess(out.index(BEGIN), out.index("Some body text."))
|
|
39
|
+
self.assertGreater(out.index(BEGIN), out.index("# My Plan"))
|
|
40
|
+
|
|
41
|
+
def test_idempotent_same_evidence_zero_diff(self):
|
|
42
|
+
once = stamp(self.DOC, ROW)
|
|
43
|
+
twice = stamp(once, ROW)
|
|
44
|
+
self.assertEqual(once, twice)
|
|
45
|
+
|
|
46
|
+
def test_rewrites_only_block_on_evidence_change(self):
|
|
47
|
+
once = stamp(self.DOC, ROW)
|
|
48
|
+
changed = stamp(once, dict(ROW, files_present=5, verdict="partial"))
|
|
49
|
+
self.assertNotEqual(once, changed)
|
|
50
|
+
self.assertEqual(changed.count(BEGIN), 1)
|
|
51
|
+
self.assertIn("partial", changed)
|
|
52
|
+
self.assertNotIn("shipped", changed)
|
|
53
|
+
|
|
54
|
+
def test_prepends_when_no_h1(self):
|
|
55
|
+
out = stamp("no heading here\n", ROW)
|
|
56
|
+
self.assertTrue(out.startswith(BEGIN))
|
|
57
|
+
|
|
58
|
+
def test_duplicate_blocks_collapse_to_one_fresh(self):
|
|
59
|
+
# Two stale blocks (e.g. from a bad merge) -> one fresh, no stale leftover.
|
|
60
|
+
block = render_block(dict(ROW, verdict="partial", files_present=1))
|
|
61
|
+
doc = f"# Plan\n\n{block}\n\nbody\n\n{block}\n"
|
|
62
|
+
out = stamp(doc, ROW)
|
|
63
|
+
self.assertEqual(out.count(BEGIN), 1)
|
|
64
|
+
self.assertEqual(out.count(END), 1)
|
|
65
|
+
self.assertIn("shipped", out)
|
|
66
|
+
self.assertNotIn("partial", out)
|
|
67
|
+
self.assertEqual(out, stamp(out, ROW)) # stable on re-run
|
|
68
|
+
|
|
69
|
+
def test_dangling_begin_does_not_duplicate(self):
|
|
70
|
+
# An orphan BEGIN (no END) must not cause a second block to be stacked.
|
|
71
|
+
doc = f"# Plan\n\n{BEGIN}\n> **Status:** truncated\n\nbody\n"
|
|
72
|
+
out = stamp(doc, ROW)
|
|
73
|
+
self.assertEqual(out.count(BEGIN), 1)
|
|
74
|
+
self.assertEqual(out.count(END), 1)
|
|
75
|
+
self.assertEqual(out, stamp(out, ROW)) # idempotent afterward
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for status_table parser/updater."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.status_table import (
|
|
10
|
+
find_status_table, update_row_status, ISSUE_NUM_RE,
|
|
11
|
+
render_issue_row, append_rows, sync_missing_rows,
|
|
12
|
+
find_canonical_status_tables, CANONICAL_MARKER,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _canonical_body(rows):
|
|
19
|
+
"""Build a body with a canonical issue table containing the given rows.
|
|
20
|
+
|
|
21
|
+
`rows` is a list of pre-rendered row strings (without leading/trailing
|
|
22
|
+
newline). A trailing narrative section is included to prove appends land
|
|
23
|
+
before it, not at end-of-body.
|
|
24
|
+
"""
|
|
25
|
+
lines = [
|
|
26
|
+
"## Issues (canonical)",
|
|
27
|
+
"",
|
|
28
|
+
f"{CANONICAL_MARKER} — auto-managed. -->",
|
|
29
|
+
"",
|
|
30
|
+
"| # | Title | Assignee | Status |",
|
|
31
|
+
"|---|---|---|---|",
|
|
32
|
+
]
|
|
33
|
+
lines.extend(rows)
|
|
34
|
+
lines.extend(["", "---", "", "## Notes", "", "Some narrative here."])
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FindStatusTableTest(unittest.TestCase):
|
|
39
|
+
def test_finds_table_with_status_col(self):
|
|
40
|
+
body = (FIXTURES / "with_status_table.md").read_text()
|
|
41
|
+
table = find_status_table(body)
|
|
42
|
+
self.assertIsNotNone(table)
|
|
43
|
+
self.assertEqual(table["status_col_index"], 2)
|
|
44
|
+
self.assertEqual(len(table["rows"]), 3)
|
|
45
|
+
|
|
46
|
+
def test_returns_none_when_no_status_table(self):
|
|
47
|
+
self.assertIsNone(find_status_table("# Just text"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UpdateRowStatusTest(unittest.TestCase):
|
|
51
|
+
def test_updates_one_row(self):
|
|
52
|
+
body = (FIXTURES / "with_status_table.md").read_text()
|
|
53
|
+
new = update_row_status(body, 4254, "✅ Shipped (PR #9999)")
|
|
54
|
+
self.assertIn("✅ Shipped (PR #9999)", new)
|
|
55
|
+
self.assertIn("✅ Shipped ", new)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RenderIssueRowTest(unittest.TestCase):
|
|
59
|
+
def test_renders_canonical_row_shape(self):
|
|
60
|
+
row = render_issue_row(487, "fix the thing", "@alice", "🔲 Open")
|
|
61
|
+
self.assertEqual(row, "| #487 | fix the thing | @alice | 🔲 Open |")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AppendRowsTest(unittest.TestCase):
|
|
65
|
+
def test_inserts_after_last_row_before_narrative(self):
|
|
66
|
+
body = _canonical_body(["| #678 | a | — | 🔲 Open |"])
|
|
67
|
+
table = find_canonical_status_tables(body)[0]
|
|
68
|
+
new = append_rows(body, table, ["| #999 | b | — | 🔲 Open |"])
|
|
69
|
+
lines = new.split("\n")
|
|
70
|
+
# New row sits directly after the existing data row...
|
|
71
|
+
self.assertEqual(lines[7], "| #999 | b | — | 🔲 Open |")
|
|
72
|
+
# ...and the narrative section survives below it.
|
|
73
|
+
self.assertIn("## Notes", new)
|
|
74
|
+
self.assertLess(new.index("#999"), new.index("## Notes"))
|
|
75
|
+
|
|
76
|
+
def test_no_rows_is_noop(self):
|
|
77
|
+
body = _canonical_body(["| #678 | a | — | 🔲 Open |"])
|
|
78
|
+
table = find_canonical_status_tables(body)[0]
|
|
79
|
+
self.assertEqual(append_rows(body, table, []), body)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SyncMissingRowsTest(unittest.TestCase):
|
|
83
|
+
def test_appends_missing_in_frontmatter_order(self):
|
|
84
|
+
body = _canonical_body([
|
|
85
|
+
"| #678 | first | — | 🔲 Open |",
|
|
86
|
+
"| #925 | second | — | ✅ Shipped |",
|
|
87
|
+
])
|
|
88
|
+
# Frontmatter lists three more than the table, deliberately out of
|
|
89
|
+
# numeric order to prove frontmatter order (not sort) is honored.
|
|
90
|
+
frontmatter_nums = [678, 925, 5195, 487, 2196]
|
|
91
|
+
issues_by_num = {
|
|
92
|
+
5195: {"number": 5195, "title": "newer", "state": "OPEN",
|
|
93
|
+
"assignees": [{"login": "bob"}]},
|
|
94
|
+
487: {"number": 487, "title": "older", "state": "CLOSED", "assignees": []},
|
|
95
|
+
2196: {"number": 2196, "title": "mid", "state": "OPEN", "assignees": []},
|
|
96
|
+
}
|
|
97
|
+
new, added = sync_missing_rows(body, frontmatter_nums, issues_by_num)
|
|
98
|
+
self.assertEqual(added, 3)
|
|
99
|
+
table = find_canonical_status_tables(new)[0]
|
|
100
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
101
|
+
self.assertEqual(nums, [678, 925, 5195, 487, 2196])
|
|
102
|
+
self.assertIn("| #5195 | newer | @bob | 🔲 Open |", new)
|
|
103
|
+
self.assertIn("| #487 | older | — | ✅ Shipped |", new)
|
|
104
|
+
|
|
105
|
+
def test_slots_low_rank_row_above_existing(self):
|
|
106
|
+
# The #79 case: frontmatter lists #487 first, but it's missing from a
|
|
107
|
+
# table whose existing rows start at #678. Option A slots #487 to the
|
|
108
|
+
# top instead of tacking it onto the end.
|
|
109
|
+
body = _canonical_body([
|
|
110
|
+
"| #678 | first | — | 🔲 Open |",
|
|
111
|
+
"| #2528 | last | — | ✅ Shipped |",
|
|
112
|
+
])
|
|
113
|
+
frontmatter_nums = [487, 678, 1556, 2528, 5195]
|
|
114
|
+
issues_by_num = {
|
|
115
|
+
487: {"number": 487, "title": "earliest", "state": "OPEN", "assignees": []},
|
|
116
|
+
1556: {"number": 1556, "title": "middle", "state": "OPEN", "assignees": []},
|
|
117
|
+
5195: {"number": 5195, "title": "newest", "state": "OPEN", "assignees": []},
|
|
118
|
+
}
|
|
119
|
+
new, added = sync_missing_rows(body, frontmatter_nums, issues_by_num)
|
|
120
|
+
self.assertEqual(added, 3)
|
|
121
|
+
table = find_canonical_status_tables(new)[0]
|
|
122
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
123
|
+
# Full table order now mirrors frontmatter: #487 at top, #1556 between
|
|
124
|
+
# #678 and #2528, #5195 at the end.
|
|
125
|
+
self.assertEqual(nums, frontmatter_nums)
|
|
126
|
+
# Existing rows are re-emitted verbatim (minimal diff).
|
|
127
|
+
self.assertIn("| #678 | first | — | 🔲 Open |", new)
|
|
128
|
+
self.assertIn("| #2528 | last | — | ✅ Shipped |", new)
|
|
129
|
+
|
|
130
|
+
def test_unranked_existing_row_imposes_no_constraint(self):
|
|
131
|
+
# A row whose issue isn't in frontmatter (manual addition) shouldn't
|
|
132
|
+
# block a missing row from slotting past it.
|
|
133
|
+
body = _canonical_body([
|
|
134
|
+
"| #9001 | manual | — | 🔲 Open |",
|
|
135
|
+
"| #678 | tracked | — | 🔲 Open |",
|
|
136
|
+
])
|
|
137
|
+
new, added = sync_missing_rows(
|
|
138
|
+
body, [487, 678],
|
|
139
|
+
{487: {"number": 487, "title": "early", "state": "OPEN", "assignees": []}},
|
|
140
|
+
)
|
|
141
|
+
self.assertEqual(added, 1)
|
|
142
|
+
table = find_canonical_status_tables(new)[0]
|
|
143
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
144
|
+
# #487 slots immediately before #678 (its frontmatter successor),
|
|
145
|
+
# leaving the unranked #9001 put.
|
|
146
|
+
self.assertEqual(nums, [9001, 487, 678])
|
|
147
|
+
|
|
148
|
+
def test_no_drift_is_noop(self):
|
|
149
|
+
body = _canonical_body(["| #678 | first | — | 🔲 Open |"])
|
|
150
|
+
new, added = sync_missing_rows(body, [678], {678: {"number": 678}})
|
|
151
|
+
self.assertEqual(added, 0)
|
|
152
|
+
self.assertEqual(new, body)
|
|
153
|
+
|
|
154
|
+
def test_missing_issue_without_fetched_data_still_appends(self):
|
|
155
|
+
body = _canonical_body(["| #678 | first | — | 🔲 Open |"])
|
|
156
|
+
new, added = sync_missing_rows(body, [678, 999], {})
|
|
157
|
+
self.assertEqual(added, 1)
|
|
158
|
+
self.assertIn("#999", new)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|