@stylusnexus/work-plan 2026.6.9
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 +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -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 +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -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 +82 -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 +40 -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/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 +109 -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_close.py +273 -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_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -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_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 +251 -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 +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -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 +166 -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_tracks.py +56 -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 +210 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Tests for GitHub state — uses mocks (gh requires auth)."""
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch, MagicMock, call
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
10
|
+
|
|
11
|
+
from lib.github_state import (
|
|
12
|
+
fetch_issues, fetch_issue, fetch_issues_concurrent,
|
|
13
|
+
fetch_repo_issues_graphql, fetch_export_issues, _normalize_gql_node,
|
|
14
|
+
extract_priority, fetch_recent_issues, short_milestone,
|
|
15
|
+
repo_visibility, _VIS_CACHE, fetch_open_issues,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExtractPriorityTest(unittest.TestCase):
|
|
20
|
+
def test_p0_label(self):
|
|
21
|
+
labels = [{"name": "priority/P0"}, {"name": "bug"}]
|
|
22
|
+
self.assertEqual(extract_priority(labels), "P0")
|
|
23
|
+
|
|
24
|
+
def test_no_priority_label_returns_p3(self):
|
|
25
|
+
self.assertEqual(extract_priority([{"name": "bug"}]), "P3")
|
|
26
|
+
|
|
27
|
+
def test_p2_label(self):
|
|
28
|
+
self.assertEqual(extract_priority([{"name": "priority/P2"}]), "P2")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ShortMilestoneTest(unittest.TestCase):
|
|
32
|
+
def test_strips_dash_suffix(self):
|
|
33
|
+
self.assertEqual(short_milestone({"title": "v0.4.0 — MVP Go-Live Gate"}), "v0.4.0")
|
|
34
|
+
|
|
35
|
+
def test_returns_full_title_when_single_word(self):
|
|
36
|
+
self.assertEqual(short_milestone({"title": "v1.0.0"}), "v1.0.0")
|
|
37
|
+
|
|
38
|
+
def test_returns_empty_for_none(self):
|
|
39
|
+
self.assertEqual(short_milestone(None), "")
|
|
40
|
+
|
|
41
|
+
def test_returns_empty_for_missing_title(self):
|
|
42
|
+
self.assertEqual(short_milestone({}), "")
|
|
43
|
+
self.assertEqual(short_milestone({"title": ""}), "")
|
|
44
|
+
|
|
45
|
+
def test_returns_empty_for_non_dict(self):
|
|
46
|
+
self.assertEqual(short_milestone("v0.4.0"), "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FetchIssuesTest(unittest.TestCase):
|
|
50
|
+
@patch("lib.github_state.subprocess.run")
|
|
51
|
+
def test_returns_list(self, mock_run):
|
|
52
|
+
mock_run.return_value = MagicMock(
|
|
53
|
+
stdout='{"number": 4254, "state": "OPEN", "labels": [{"name": "priority/P0"}], "title": "polls"}',
|
|
54
|
+
returncode=0,
|
|
55
|
+
)
|
|
56
|
+
result = fetch_issues("stylusnexus/CritForge", [4254])
|
|
57
|
+
self.assertEqual(len(result), 1)
|
|
58
|
+
self.assertEqual(result[0]["number"], 4254)
|
|
59
|
+
|
|
60
|
+
def test_empty_returns_empty(self):
|
|
61
|
+
self.assertEqual(fetch_issues("stylusnexus/CritForge", []), [])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FetchRecentIssuesTest(unittest.TestCase):
|
|
65
|
+
@patch("lib.github_state.subprocess.run")
|
|
66
|
+
def test_calls_gh_with_search(self, mock_run):
|
|
67
|
+
mock_run.return_value = MagicMock(
|
|
68
|
+
stdout='[{"number": 9999, "title": "new", "labels": [], "createdAt": "2026-04-28T10:00:00Z"}]',
|
|
69
|
+
returncode=0,
|
|
70
|
+
)
|
|
71
|
+
result = fetch_recent_issues("stylusnexus/CritForge", since_iso="2026-04-27")
|
|
72
|
+
self.assertEqual(len(result), 1)
|
|
73
|
+
self.assertEqual(result[0]["number"], 9999)
|
|
74
|
+
called_args = mock_run.call_args[0][0]
|
|
75
|
+
self.assertIn("created:>=2026-04-27", " ".join(called_args))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RepoVisibilityTest(unittest.TestCase):
|
|
79
|
+
@patch("lib.github_state.subprocess.run")
|
|
80
|
+
def test_returns_public(self, m):
|
|
81
|
+
_VIS_CACHE.clear()
|
|
82
|
+
m.return_value = MagicMock(returncode=0, stdout='{"visibility":"PUBLIC"}')
|
|
83
|
+
self.assertEqual(repo_visibility("o/r"), "PUBLIC")
|
|
84
|
+
|
|
85
|
+
@patch("lib.github_state.subprocess.run")
|
|
86
|
+
def test_none_on_failure(self, m):
|
|
87
|
+
_VIS_CACHE.clear()
|
|
88
|
+
m.return_value = MagicMock(returncode=1, stdout="", stderr="x")
|
|
89
|
+
self.assertIsNone(repo_visibility("o/r"))
|
|
90
|
+
|
|
91
|
+
def test_none_for_empty_repo(self):
|
|
92
|
+
_VIS_CACHE.clear()
|
|
93
|
+
self.assertIsNone(repo_visibility(""))
|
|
94
|
+
self.assertIsNone(repo_visibility(None))
|
|
95
|
+
|
|
96
|
+
@patch("lib.github_state.subprocess.run")
|
|
97
|
+
def test_memoizes_result(self, m):
|
|
98
|
+
_VIS_CACHE.clear()
|
|
99
|
+
m.return_value = MagicMock(returncode=0, stdout='{"visibility":"PRIVATE"}')
|
|
100
|
+
repo_visibility("o/r")
|
|
101
|
+
repo_visibility("o/r")
|
|
102
|
+
self.assertEqual(m.call_count, 1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_ISSUE_JSON = '{"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": null, "url": "u", "closedAt": null, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}'
|
|
106
|
+
_ISSUE_DICT = {"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": None, "url": "u", "closedAt": None, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FetchIssueTest(unittest.TestCase):
|
|
110
|
+
"""Unit tests for the single-issue primitive fetch_issue()."""
|
|
111
|
+
|
|
112
|
+
@patch("lib.github_state.subprocess.run")
|
|
113
|
+
def test_returns_dict_on_success(self, mock_run):
|
|
114
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=_ISSUE_JSON)
|
|
115
|
+
result = fetch_issue("org/repo", 1)
|
|
116
|
+
self.assertIsNotNone(result)
|
|
117
|
+
self.assertEqual(result["number"], 1)
|
|
118
|
+
|
|
119
|
+
@patch("lib.github_state.subprocess.run")
|
|
120
|
+
def test_returns_none_on_nonzero_returncode(self, mock_run):
|
|
121
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
|
122
|
+
result = fetch_issue("org/repo", 1)
|
|
123
|
+
self.assertIsNone(result)
|
|
124
|
+
|
|
125
|
+
@patch("lib.github_state.subprocess.run")
|
|
126
|
+
def test_returns_none_on_json_decode_error(self, mock_run):
|
|
127
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="not-json{{{")
|
|
128
|
+
result = fetch_issue("org/repo", 1)
|
|
129
|
+
self.assertIsNone(result)
|
|
130
|
+
|
|
131
|
+
@patch("lib.github_state.subprocess.run", side_effect=FileNotFoundError("gh not found"))
|
|
132
|
+
def test_fetch_issue_returns_none_when_gh_missing(self, _):
|
|
133
|
+
self.assertIsNone(fetch_issue("org/repo", 1))
|
|
134
|
+
|
|
135
|
+
@patch("lib.github_state.subprocess.run")
|
|
136
|
+
def test_calls_gh_with_correct_args(self, mock_run):
|
|
137
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=_ISSUE_JSON)
|
|
138
|
+
fetch_issue("org/repo", 42)
|
|
139
|
+
args = mock_run.call_args[0][0]
|
|
140
|
+
self.assertIn("gh", args)
|
|
141
|
+
self.assertIn("42", args)
|
|
142
|
+
self.assertIn("--repo", args)
|
|
143
|
+
self.assertIn("org/repo", args)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FetchIssuesConcurrentTest(unittest.TestCase):
|
|
147
|
+
"""Unit tests for the concurrent batch fetch fetch_issues_concurrent()."""
|
|
148
|
+
|
|
149
|
+
def _make_fake_fetch(self, missing_num=None):
|
|
150
|
+
"""Return a fake fetch_issue that returns a canned dict or None."""
|
|
151
|
+
def _fake(repo, number):
|
|
152
|
+
if number == missing_num:
|
|
153
|
+
return None
|
|
154
|
+
return {"number": number, "state": "OPEN", "labels": [], "title": f"Issue {number}",
|
|
155
|
+
"milestone": None, "url": f"u/{number}", "closedAt": None, "body": "",
|
|
156
|
+
"updatedAt": "2026-01-01T00:00:00Z", "assignees": []}
|
|
157
|
+
return _fake
|
|
158
|
+
|
|
159
|
+
@patch("lib.github_state.fetch_issue")
|
|
160
|
+
def test_returns_keyed_dict_for_successful_fetches(self, mock_fi):
|
|
161
|
+
mock_fi.side_effect = self._make_fake_fetch()
|
|
162
|
+
jobs = [("org/repo", 1), ("org/repo", 2)]
|
|
163
|
+
result = fetch_issues_concurrent(jobs)
|
|
164
|
+
self.assertIn(("org/repo", 1), result)
|
|
165
|
+
self.assertIn(("org/repo", 2), result)
|
|
166
|
+
self.assertEqual(result[("org/repo", 1)]["number"], 1)
|
|
167
|
+
self.assertEqual(result[("org/repo", 2)]["number"], 2)
|
|
168
|
+
|
|
169
|
+
@patch("lib.github_state.fetch_issue")
|
|
170
|
+
def test_omits_none_results(self, mock_fi):
|
|
171
|
+
mock_fi.side_effect = self._make_fake_fetch(missing_num=99)
|
|
172
|
+
jobs = [("org/repo", 1), ("org/repo", 99)]
|
|
173
|
+
result = fetch_issues_concurrent(jobs)
|
|
174
|
+
self.assertIn(("org/repo", 1), result)
|
|
175
|
+
self.assertNotIn(("org/repo", 99), result)
|
|
176
|
+
|
|
177
|
+
@patch("lib.github_state.fetch_issue")
|
|
178
|
+
def test_dedupes_duplicate_jobs(self, mock_fi):
|
|
179
|
+
mock_fi.side_effect = self._make_fake_fetch()
|
|
180
|
+
# same (repo, number) appears twice — should only call fetch_issue once
|
|
181
|
+
jobs = [("org/repo", 5), ("org/repo", 5)]
|
|
182
|
+
result = fetch_issues_concurrent(jobs)
|
|
183
|
+
self.assertIn(("org/repo", 5), result)
|
|
184
|
+
self.assertEqual(mock_fi.call_count, 1)
|
|
185
|
+
|
|
186
|
+
@patch("lib.github_state.fetch_issue")
|
|
187
|
+
def test_empty_jobs_returns_empty_dict(self, mock_fi):
|
|
188
|
+
result = fetch_issues_concurrent([])
|
|
189
|
+
self.assertEqual(result, {})
|
|
190
|
+
mock_fi.assert_not_called()
|
|
191
|
+
|
|
192
|
+
@patch("lib.github_state.fetch_issue")
|
|
193
|
+
def test_different_repos_are_distinct_keys(self, mock_fi):
|
|
194
|
+
mock_fi.side_effect = self._make_fake_fetch()
|
|
195
|
+
jobs = [("org/repoA", 1), ("org/repoB", 1)]
|
|
196
|
+
result = fetch_issues_concurrent(jobs)
|
|
197
|
+
self.assertIn(("org/repoA", 1), result)
|
|
198
|
+
self.assertIn(("org/repoB", 1), result)
|
|
199
|
+
self.assertEqual(mock_fi.call_count, 2)
|
|
200
|
+
|
|
201
|
+
@patch("lib.github_state.fetch_issue")
|
|
202
|
+
def test_all_failures_returns_empty_dict(self, mock_fi):
|
|
203
|
+
mock_fi.return_value = None
|
|
204
|
+
jobs = [("org/repo", 10), ("org/repo", 11)]
|
|
205
|
+
result = fetch_issues_concurrent(jobs)
|
|
206
|
+
self.assertEqual(result, {})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class FetchIssuesAfterRefactorTest(unittest.TestCase):
|
|
210
|
+
"""Verify fetch_issues still returns the sequential list after refactor."""
|
|
211
|
+
|
|
212
|
+
@patch("lib.github_state.subprocess.run")
|
|
213
|
+
def test_returns_list_in_order(self, mock_run):
|
|
214
|
+
responses = [
|
|
215
|
+
MagicMock(returncode=0, stdout='{"number": 10, "state": "OPEN", "labels": [], "title": "a"}'),
|
|
216
|
+
MagicMock(returncode=0, stdout='{"number": 20, "state": "CLOSED", "labels": [], "title": "b"}'),
|
|
217
|
+
]
|
|
218
|
+
mock_run.side_effect = responses
|
|
219
|
+
result = fetch_issues("org/repo", [10, 20])
|
|
220
|
+
self.assertEqual(len(result), 2)
|
|
221
|
+
self.assertEqual(result[0]["number"], 10)
|
|
222
|
+
self.assertEqual(result[1]["number"], 20)
|
|
223
|
+
|
|
224
|
+
@patch("lib.github_state.subprocess.run")
|
|
225
|
+
def test_skips_failed_fetches(self, mock_run):
|
|
226
|
+
responses = [
|
|
227
|
+
MagicMock(returncode=0, stdout='{"number": 10, "state": "OPEN", "labels": []}'),
|
|
228
|
+
MagicMock(returncode=1, stdout="", stderr="not found"),
|
|
229
|
+
MagicMock(returncode=0, stdout='{"number": 30, "state": "OPEN", "labels": []}'),
|
|
230
|
+
]
|
|
231
|
+
mock_run.side_effect = responses
|
|
232
|
+
result = fetch_issues("org/repo", [10, 20, 30])
|
|
233
|
+
self.assertEqual(len(result), 2)
|
|
234
|
+
self.assertEqual(result[0]["number"], 10)
|
|
235
|
+
self.assertEqual(result[1]["number"], 30)
|
|
236
|
+
|
|
237
|
+
def test_empty_returns_empty(self):
|
|
238
|
+
self.assertEqual(fetch_issues("org/repo", []), [])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _gql_response(nodes: dict) -> str:
|
|
242
|
+
"""Build a GraphQL JSON response string from a {alias: node|None} dict."""
|
|
243
|
+
return json.dumps({"data": {"repository": nodes}})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# A canned mixed response: an Issue, a MERGED PullRequest, and a null node.
|
|
247
|
+
_GQL_NODES = {
|
|
248
|
+
"i487": {"number": 487, "title": "An issue", "state": "OPEN",
|
|
249
|
+
"assignees": {"nodes": [{"login": "x"}]},
|
|
250
|
+
"milestone": {"title": "v1.0 — gate"}},
|
|
251
|
+
"i99": {"number": 99, "title": "A PR", "state": "MERGED",
|
|
252
|
+
"assignees": {"nodes": []}, "milestone": None},
|
|
253
|
+
"i1556": None,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class NormalizeGqlNodeTest(unittest.TestCase):
|
|
258
|
+
"""Unit tests for _normalize_gql_node()."""
|
|
259
|
+
|
|
260
|
+
def test_none_node_returns_none(self):
|
|
261
|
+
self.assertIsNone(_normalize_gql_node(None))
|
|
262
|
+
|
|
263
|
+
def test_issue_node_normalized(self):
|
|
264
|
+
out = _normalize_gql_node(_GQL_NODES["i487"])
|
|
265
|
+
self.assertEqual(out["number"], 487)
|
|
266
|
+
self.assertEqual(out["title"], "An issue")
|
|
267
|
+
self.assertEqual(out["state"], "OPEN")
|
|
268
|
+
self.assertEqual(out["assignees"], [{"login": "x"}])
|
|
269
|
+
self.assertEqual(out["milestone"], {"title": "v1.0 — gate"})
|
|
270
|
+
|
|
271
|
+
def test_pr_state_preserved(self):
|
|
272
|
+
out = _normalize_gql_node(_GQL_NODES["i99"])
|
|
273
|
+
self.assertEqual(out["state"], "MERGED")
|
|
274
|
+
self.assertEqual(out["assignees"], [])
|
|
275
|
+
self.assertIsNone(out["milestone"])
|
|
276
|
+
|
|
277
|
+
def test_missing_milestone_is_none(self):
|
|
278
|
+
out = _normalize_gql_node({"number": 1, "title": "t", "state": "OPEN"})
|
|
279
|
+
self.assertIsNone(out["milestone"])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class FetchRepoIssuesGraphqlTest(unittest.TestCase):
|
|
283
|
+
"""Unit tests for the batched GraphQL primitive fetch_repo_issues_graphql()."""
|
|
284
|
+
|
|
285
|
+
@patch("lib.github_state.subprocess.run")
|
|
286
|
+
def test_returns_normalized_keyed_dict_null_omitted(self, mock_run):
|
|
287
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=_gql_response(_GQL_NODES))
|
|
288
|
+
result = fetch_repo_issues_graphql("org/repo", [487, 99, 1556])
|
|
289
|
+
# null i1556 omitted
|
|
290
|
+
self.assertEqual(set(result.keys()), {487, 99})
|
|
291
|
+
# normalized shapes
|
|
292
|
+
self.assertEqual(result[487]["assignees"], [{"login": "x"}])
|
|
293
|
+
self.assertEqual(result[487]["milestone"], {"title": "v1.0 — gate"})
|
|
294
|
+
# PR state preserved
|
|
295
|
+
self.assertEqual(result[99]["state"], "MERGED")
|
|
296
|
+
self.assertIsNone(result[99]["milestone"])
|
|
297
|
+
|
|
298
|
+
@patch("lib.github_state.subprocess.run")
|
|
299
|
+
def test_uses_gh_api_graphql(self, mock_run):
|
|
300
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=_gql_response({}))
|
|
301
|
+
fetch_repo_issues_graphql("org/repo", [1])
|
|
302
|
+
args = mock_run.call_args[0][0]
|
|
303
|
+
self.assertEqual(args[:3], ["gh", "api", "graphql"])
|
|
304
|
+
|
|
305
|
+
@patch("lib.github_state.subprocess.run")
|
|
306
|
+
def test_chunks_into_multiple_calls(self, mock_run):
|
|
307
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=_gql_response({}))
|
|
308
|
+
# 5 numbers, chunk=2 → 3 chunks → 3 subprocess calls
|
|
309
|
+
fetch_repo_issues_graphql("org/repo", [1, 2, 3, 4, 5], chunk=2)
|
|
310
|
+
self.assertEqual(mock_run.call_count, 3)
|
|
311
|
+
|
|
312
|
+
@patch("lib.github_state.subprocess.run")
|
|
313
|
+
def test_nonzero_returncode_chunk_yields_empty(self, mock_run):
|
|
314
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
|
315
|
+
self.assertEqual(fetch_repo_issues_graphql("org/repo", [1, 2]), {})
|
|
316
|
+
|
|
317
|
+
@patch("lib.github_state.subprocess.run")
|
|
318
|
+
def test_empty_stdout_chunk_yields_empty(self, mock_run):
|
|
319
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
|
320
|
+
self.assertEqual(fetch_repo_issues_graphql("org/repo", [1, 2]), {})
|
|
321
|
+
|
|
322
|
+
@patch("lib.github_state.subprocess.run")
|
|
323
|
+
def test_non_json_chunk_yields_empty(self, mock_run):
|
|
324
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="not-json{{{")
|
|
325
|
+
self.assertEqual(fetch_repo_issues_graphql("org/repo", [1, 2]), {})
|
|
326
|
+
|
|
327
|
+
@patch("lib.github_state.subprocess.run", side_effect=FileNotFoundError("gh not found"))
|
|
328
|
+
def test_subprocess_exception_yields_empty(self, _):
|
|
329
|
+
self.assertEqual(fetch_repo_issues_graphql("org/repo", [1, 2]), {})
|
|
330
|
+
|
|
331
|
+
@patch("lib.github_state.subprocess.run")
|
|
332
|
+
def test_invalid_repo_no_gh_call(self, mock_run):
|
|
333
|
+
result = fetch_repo_issues_graphql("not-a-repo", [1, 2])
|
|
334
|
+
self.assertEqual(result, {})
|
|
335
|
+
mock_run.assert_not_called()
|
|
336
|
+
|
|
337
|
+
@patch("lib.github_state.subprocess.run")
|
|
338
|
+
def test_empty_numbers_no_gh_call(self, mock_run):
|
|
339
|
+
result = fetch_repo_issues_graphql("org/repo", [])
|
|
340
|
+
self.assertEqual(result, {})
|
|
341
|
+
mock_run.assert_not_called()
|
|
342
|
+
|
|
343
|
+
@patch("lib.github_state.subprocess.run")
|
|
344
|
+
def test_non_int_numbers_yields_empty(self, mock_run):
|
|
345
|
+
self.assertEqual(fetch_repo_issues_graphql("org/repo", ["not-a-number"]), {})
|
|
346
|
+
mock_run.assert_not_called()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class FetchExportIssuesTest(unittest.TestCase):
|
|
350
|
+
"""Unit tests for the GraphQL-primary + per-issue fallback fetch_export_issues()."""
|
|
351
|
+
|
|
352
|
+
def _make_map(self, *issues):
|
|
353
|
+
"""Build a {number: issue} map from a list of issue dicts."""
|
|
354
|
+
return {i["number"]: i for i in issues}
|
|
355
|
+
|
|
356
|
+
_ISSUE_1 = {"number": 1, "title": "First", "state": "OPEN", "assignees": [], "milestone": None}
|
|
357
|
+
_ISSUE_2 = {"number": 2, "title": "Second", "state": "CLOSED", "assignees": [], "milestone": None}
|
|
358
|
+
|
|
359
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
360
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
361
|
+
def test_graphql_called_once_per_repo(self, mock_gql, mock_fic):
|
|
362
|
+
"""fetch_repo_issues_graphql must be called ONCE per repo, not once per issue."""
|
|
363
|
+
mock_gql.return_value = self._make_map(self._ISSUE_1, self._ISSUE_2)
|
|
364
|
+
mock_fic.return_value = {}
|
|
365
|
+
fetch_export_issues({"org/repo": [1, 2]})
|
|
366
|
+
self.assertEqual(mock_gql.call_count, 1)
|
|
367
|
+
|
|
368
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
369
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
370
|
+
def test_result_keyed_by_repo_number_tuple(self, mock_gql, mock_fic):
|
|
371
|
+
mock_gql.return_value = self._make_map(self._ISSUE_1, self._ISSUE_2)
|
|
372
|
+
mock_fic.return_value = {}
|
|
373
|
+
result = fetch_export_issues({"org/repo": [1, 2]})
|
|
374
|
+
self.assertIn(("org/repo", 1), result)
|
|
375
|
+
self.assertIn(("org/repo", 2), result)
|
|
376
|
+
self.assertEqual(result[("org/repo", 1)]["title"], "First")
|
|
377
|
+
|
|
378
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
379
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
380
|
+
def test_missing_number_triggers_fallback(self, mock_gql, mock_fic):
|
|
381
|
+
"""A number absent from the GraphQL result goes to fetch_issues_concurrent."""
|
|
382
|
+
mock_gql.return_value = self._make_map(self._ISSUE_1) # only issue 1
|
|
383
|
+
mock_fic.return_value = {("org/repo", 99): {"number": 99, "title": "Issue99",
|
|
384
|
+
"state": "CLOSED", "assignees": [],
|
|
385
|
+
"milestone": None}}
|
|
386
|
+
result = fetch_export_issues({"org/repo": [1, 99]})
|
|
387
|
+
self.assertIn(("org/repo", 1), result) # from GraphQL
|
|
388
|
+
self.assertIn(("org/repo", 99), result) # from fallback
|
|
389
|
+
self.assertEqual(result[("org/repo", 99)]["title"], "Issue99")
|
|
390
|
+
mock_fic.assert_called_once()
|
|
391
|
+
fallback_jobs = list(mock_fic.call_args[0][0])
|
|
392
|
+
self.assertEqual(fallback_jobs, [("org/repo", 99)])
|
|
393
|
+
|
|
394
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
395
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
396
|
+
def test_no_fallback_when_graphql_covers_all(self, mock_gql, mock_fic):
|
|
397
|
+
mock_gql.return_value = self._make_map(self._ISSUE_1, self._ISSUE_2)
|
|
398
|
+
mock_fic.return_value = {}
|
|
399
|
+
fetch_export_issues({"org/repo": [1, 2]})
|
|
400
|
+
mock_fic.assert_not_called()
|
|
401
|
+
|
|
402
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
403
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
404
|
+
def test_multiple_repos_graphql_called_once_each(self, mock_gql, mock_fic):
|
|
405
|
+
def _side(repo, numbers, max_workers=8):
|
|
406
|
+
if repo == "org/repoA":
|
|
407
|
+
return self._make_map(self._ISSUE_1)
|
|
408
|
+
return self._make_map(self._ISSUE_2)
|
|
409
|
+
mock_gql.side_effect = _side
|
|
410
|
+
mock_fic.return_value = {}
|
|
411
|
+
result = fetch_export_issues({"org/repoA": [1], "org/repoB": [2]})
|
|
412
|
+
self.assertEqual(mock_gql.call_count, 2)
|
|
413
|
+
self.assertIn(("org/repoA", 1), result)
|
|
414
|
+
self.assertIn(("org/repoB", 2), result)
|
|
415
|
+
|
|
416
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
417
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
418
|
+
def test_empty_input_returns_empty(self, mock_gql, mock_fic):
|
|
419
|
+
result = fetch_export_issues({})
|
|
420
|
+
self.assertEqual(result, {})
|
|
421
|
+
mock_gql.assert_not_called()
|
|
422
|
+
mock_fic.assert_not_called()
|
|
423
|
+
|
|
424
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
425
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
426
|
+
def test_repo_with_empty_numbers_skipped(self, mock_gql, mock_fic):
|
|
427
|
+
result = fetch_export_issues({"org/repo": []})
|
|
428
|
+
self.assertEqual(result, {})
|
|
429
|
+
mock_gql.assert_not_called()
|
|
430
|
+
mock_fic.assert_not_called()
|
|
431
|
+
|
|
432
|
+
@patch("lib.github_state.fetch_issues_concurrent")
|
|
433
|
+
@patch("lib.github_state.fetch_repo_issues_graphql")
|
|
434
|
+
def test_none_repo_skipped(self, mock_gql, mock_fic):
|
|
435
|
+
result = fetch_export_issues({None: [1, 2]})
|
|
436
|
+
self.assertEqual(result, {})
|
|
437
|
+
mock_gql.assert_not_called()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class FetchOpenIssuesTest(unittest.TestCase):
|
|
441
|
+
"""Unit tests for fetch_open_issues() — all gh calls mocked."""
|
|
442
|
+
|
|
443
|
+
_OPEN_ROWS = [
|
|
444
|
+
{"number": 5, "title": "Open one", "state": "OPEN", "assignees": [], "milestone": None},
|
|
445
|
+
{"number": 7, "title": "Open two", "state": "OPEN", "assignees": [{"login": "eve"}], "milestone": {"title": "v1.0"}},
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
@patch("lib.github_state.subprocess.run")
|
|
449
|
+
def test_returns_rows_on_success(self, mock_run):
|
|
450
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=json.dumps(self._OPEN_ROWS))
|
|
451
|
+
result = fetch_open_issues("o/r")
|
|
452
|
+
self.assertEqual(result, self._OPEN_ROWS)
|
|
453
|
+
|
|
454
|
+
@patch("lib.github_state.subprocess.run")
|
|
455
|
+
def test_calls_gh_issue_list_open(self, mock_run):
|
|
456
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="[]")
|
|
457
|
+
fetch_open_issues("o/r")
|
|
458
|
+
args = mock_run.call_args[0][0]
|
|
459
|
+
self.assertIn("gh", args)
|
|
460
|
+
self.assertIn("issue", args)
|
|
461
|
+
self.assertIn("list", args)
|
|
462
|
+
self.assertIn("--repo", args)
|
|
463
|
+
self.assertIn("o/r", args)
|
|
464
|
+
# must request open issues (flag + value, space-separated) and the JSON fields
|
|
465
|
+
self.assertIn("--state", args)
|
|
466
|
+
self.assertEqual(args[args.index("--state") + 1], "open")
|
|
467
|
+
self.assertIn("number,title,state,assignees,milestone", " ".join(args))
|
|
468
|
+
|
|
469
|
+
@patch("lib.github_state.subprocess.run")
|
|
470
|
+
def test_nonzero_returncode_returns_empty(self, mock_run):
|
|
471
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
|
472
|
+
self.assertEqual(fetch_open_issues("o/r"), [])
|
|
473
|
+
|
|
474
|
+
@patch("lib.github_state.subprocess.run")
|
|
475
|
+
def test_empty_stdout_returns_empty(self, mock_run):
|
|
476
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
|
477
|
+
self.assertEqual(fetch_open_issues("o/r"), [])
|
|
478
|
+
|
|
479
|
+
@patch("lib.github_state.subprocess.run")
|
|
480
|
+
def test_bad_json_returns_empty(self, mock_run):
|
|
481
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="not-json{{")
|
|
482
|
+
self.assertEqual(fetch_open_issues("o/r"), [])
|
|
483
|
+
|
|
484
|
+
@patch("lib.github_state.subprocess.run", side_effect=Exception("gh missing"))
|
|
485
|
+
def test_exception_returns_empty(self, _):
|
|
486
|
+
self.assertEqual(fetch_open_issues("o/r"), [])
|
|
487
|
+
|
|
488
|
+
@patch("lib.github_state.subprocess.run")
|
|
489
|
+
def test_bad_repo_returns_empty_without_calling_gh(self, mock_run):
|
|
490
|
+
self.assertEqual(fetch_open_issues("notarepo"), [])
|
|
491
|
+
mock_run.assert_not_called()
|
|
492
|
+
|
|
493
|
+
@patch("lib.github_state.subprocess.run")
|
|
494
|
+
def test_custom_limit_passed(self, mock_run):
|
|
495
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="[]")
|
|
496
|
+
fetch_open_issues("o/r", limit=500)
|
|
497
|
+
args = mock_run.call_args[0][0]
|
|
498
|
+
self.assertIn("500", args)
|
|
499
|
+
|
|
500
|
+
@patch("lib.github_state.subprocess.run")
|
|
501
|
+
def test_returns_list_type(self, mock_run):
|
|
502
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=json.dumps(self._OPEN_ROWS))
|
|
503
|
+
result = fetch_open_issues("o/r")
|
|
504
|
+
self.assertIsInstance(result, list)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == "__main__":
|
|
508
|
+
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()
|